diff --git a/.gitignore b/.gitignore index bbe6e5f..7cee59f 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,8 @@ loremind-docs/ # Docker Compose override (dev uniquement, non-distribue aux end users) # ============================================================================ docker-compose.override.yml + +# ============================================================================ +# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite) +# ============================================================================ +relay/ diff --git a/core/pom.xml b/core/pom.xml index c5c74de..cc8897a 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.7.2 + 0.8.0 LoreMind Core Backend Core - Architecture Hexagonale @@ -83,6 +83,19 @@ minio 8.5.11 + + + + com.nimbusds + nimbus-jose-jwt + 9.40 + + + org.bouncycastle + bcprov-jdk18on + 1.78.1 + diff --git a/core/src/main/java/com/loremind/LoreMindApplication.java b/core/src/main/java/com/loremind/LoreMindApplication.java index 5ad7700..3aa11a0 100644 --- a/core/src/main/java/com/loremind/LoreMindApplication.java +++ b/core/src/main/java/com/loremind/LoreMindApplication.java @@ -2,12 +2,14 @@ package com.loremind; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; /** * Classe principale de l'application LoreMind. * Point d'entrée Spring Boot qui démarre l'application. */ @SpringBootApplication +@EnableScheduling public class LoreMindApplication { public static void main(String[] args) { diff --git a/core/src/main/java/com/loremind/application/licensing/LicenseService.java b/core/src/main/java/com/loremind/application/licensing/LicenseService.java new file mode 100644 index 0000000..0a437a5 --- /dev/null +++ b/core/src/main/java/com/loremind/application/licensing/LicenseService.java @@ -0,0 +1,261 @@ +package com.loremind.application.licensing; + +import com.loremind.domain.licensing.License; +import com.loremind.domain.licensing.LicenseClaims; +import com.loremind.domain.licensing.LicenseSnapshot; +import com.loremind.domain.licensing.LicenseStatus; +import com.loremind.domain.licensing.RegistryCredentials; +import com.loremind.domain.licensing.ports.JwtVerifier; +import com.loremind.domain.licensing.ports.LicenseRelay; +import com.loremind.domain.licensing.ports.LicenseRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +/** + * Service application pour la gestion de la licence Patreon. + *

+ * Responsabilites : + *

+ */ +@Service +public class LicenseService { + + private static final Logger log = LoggerFactory.getLogger(LicenseService.class); + + private final LicenseRepository repository; + private final JwtVerifier jwtVerifier; + private final LicenseRelay relay; + private final long gracePeriodSeconds; + private final long refreshBeforeExpirySeconds; + + public LicenseService( + LicenseRepository repository, + JwtVerifier jwtVerifier, + LicenseRelay relay, + @Value("${licensing.grace-period-days:14}") int gracePeriodDays, + @Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays) { + this.repository = repository; + this.jwtVerifier = jwtVerifier; + this.relay = relay; + this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L; + this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L; + } + + /** + * @return true si le verifier est configure (cle publique presente). + * L'UI peut masquer toute la section Patreon si false. + */ + public boolean isLicensingEnabled() { + return jwtVerifier.isConfigured(); + } + + /** + * Genere ou retourne l'instance_id stable de cette installation. + * Stocke dans la licence elle-meme. Si pas de licence, en cree un volatil + * (sera persiste a la prochaine connexion). + */ + public String getOrCreateInstanceId() { + return repository.findCurrent() + .map(License::getInstanceId) + .orElseGet(() -> "li-" + UUID.randomUUID()); + } + + /** + * Construit l'URL OAuth pour ouvrir dans le navigateur de l'utilisateur. + */ + public String buildConnectUrl() { + return relay.buildConnectUrl(getOrCreateInstanceId()); + } + + /** + * Installe un JWT recu du relais (l'utilisateur l'a colle dans l'UI ou + * recu via deep-link). Verifie la signature, extrait les claims, persiste. + */ + public LicenseSnapshot installToken(String rawJwt) throws InstallException { + if (!jwtVerifier.isConfigured()) { + throw new InstallException("Licensing feature not enabled (no public key configured)"); + } + LicenseClaims claims; + try { + claims = jwtVerifier.verify(rawJwt); + } catch (JwtVerifier.JwtVerificationException e) { + throw new InstallException("Invalid JWT: " + e.getMessage()); + } + + Instant now = Instant.now(); + if (claims.expiresAt().isBefore(now)) { + throw new InstallException("JWT already expired"); + } + + Optional existing = repository.findCurrent(); + License toSave = License.builder() + .id("current") + .rawJwt(rawJwt) + .patreonUserId(claims.subject()) + .tierId(claims.tierId()) + .instanceId(claims.instanceId()) + .issuedAt(claims.issuedAt()) + .expiresAt(claims.expiresAt()) + .lastRefreshAttemptAt(now) + .lastRefreshSucceeded(true) + // Au premier install, on active le canal beta par defaut. + // Sur reinstall apres deconnexion, on respecte la valeur precedente. + .betaChannelEnabled(existing.map(License::isBetaChannelEnabled).orElse(true)) + .createdAt(existing.map(License::getCreatedAt).orElse(now)) + .build(); + + License saved = repository.save(toSave); + log.info("Patreon license installed for user={} tier={} expires={}", + saved.getPatreonUserId(), saved.getTierId(), saved.getExpiresAt()); + return snapshotOf(saved, now); + } + + /** + * Etat courant de la licence pour exposition UI / decision technique. + */ + public LicenseSnapshot getCurrentSnapshot() { + Optional opt = repository.findCurrent(); + if (opt.isEmpty()) return LicenseSnapshot.none(); + return snapshotOf(opt.get(), Instant.now()); + } + + /** + * Supprime la licence (deconnexion volontaire de Patreon par l'utilisateur). + */ + public void disconnect() { + repository.deleteCurrent(); + log.info("Patreon license removed (user disconnect)"); + } + + /** + * Active ou desactive le canal beta. Necessite une licence valide ou en grace. + */ + public LicenseSnapshot setBetaChannelEnabled(boolean enabled) { + License current = repository.findCurrent() + .orElseThrow(() -> new IllegalStateException("No license installed")); + current.setBetaChannelEnabled(enabled); + License saved = repository.save(current); + return snapshotOf(saved, Instant.now()); + } + + /** + * Tente un refresh si la licence est proche de l'expiration. Idempotent. + * Appele par le daemon planifie + manuellement via l'UI ("Reessayer"). + * + * @return true si un refresh a ete tente (avec ou sans succes) + */ + public boolean refreshIfNeeded() { + Optional opt = repository.findCurrent(); + if (opt.isEmpty()) return false; + License current = opt.get(); + Instant now = Instant.now(); + long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds(); + if (secondsUntilExpiry > refreshBeforeExpirySeconds) { + return false; + } + return doRefresh(current, now); + } + + /** + * Force un refresh immediat (bouton UI "Reessayer maintenant"). + */ + public boolean forceRefresh() { + return repository.findCurrent() + .map(license -> doRefresh(license, Instant.now())) + .orElse(false); + } + + private boolean doRefresh(License current, Instant now) { + log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt()); + try { + String newJwt = relay.refreshToken(current.getRawJwt()); + LicenseClaims claims = jwtVerifier.verify(newJwt); + + current.setRawJwt(newJwt); + current.setIssuedAt(claims.issuedAt()); + current.setExpiresAt(claims.expiresAt()); + current.setTierId(claims.tierId()); + current.setLastRefreshAttemptAt(now); + current.setLastRefreshSucceeded(true); + repository.save(current); + log.info("License refreshed successfully (new expiry {})", claims.expiresAt()); + return true; + } catch (LicenseRelay.RelayException e) { + current.setLastRefreshAttemptAt(now); + current.setLastRefreshSucceeded(false); + repository.save(current); + if (e.getKind() == LicenseRelay.RelayErrorKind.REJECTED) { + log.warn("Relay rejected refresh ({}): tier may have been cancelled", e.getMessage()); + } else { + log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage()); + } + return true; + } catch (JwtVerifier.JwtVerificationException e) { + current.setLastRefreshAttemptAt(now); + current.setLastRefreshSucceeded(false); + repository.save(current); + log.error("Relay returned a JWT that fails verification: {}", e.getMessage()); + return true; + } + } + + /** + * Recupere les credentials registry pour pull du canal beta. + * @return empty si pas de licence valide ou relais en echec + */ + public Optional fetchRegistryCredentials() { + LicenseSnapshot snap = getCurrentSnapshot(); + if (snap.status() != LicenseStatus.VALID && snap.status() != LicenseStatus.GRACE) { + return Optional.empty(); + } + License current = repository.findCurrent().orElse(null); + if (current == null) return Optional.empty(); + try { + return Optional.of(relay.fetchRegistryCredentials(current.getRawJwt())); + } catch (LicenseRelay.RelayException e) { + log.warn("Cannot fetch registry credentials ({}): {}", e.getKind(), e.getMessage()); + return Optional.empty(); + } + } + + private LicenseSnapshot snapshotOf(License l, Instant now) { + LicenseStatus status = computeStatus(l, now); + return new LicenseSnapshot( + status, + l.getPatreonUserId(), + l.getTierId(), + l.getInstanceId(), + l.getExpiresAt(), + l.getLastRefreshAttemptAt(), + l.isLastRefreshSucceeded(), + l.isBetaChannelEnabled() + ); + } + + private LicenseStatus computeStatus(License l, Instant now) { + if (l.getExpiresAt() == null) return LicenseStatus.NONE; + if (now.isBefore(l.getExpiresAt())) return LicenseStatus.VALID; + long secondsPastExpiry = Duration.between(l.getExpiresAt(), now).getSeconds(); + if (secondsPastExpiry <= gracePeriodSeconds) return LicenseStatus.GRACE; + return LicenseStatus.EXPIRED; + } + + public static class InstallException extends Exception { + public InstallException(String message) { + super(message); + } + } +} diff --git a/core/src/main/java/com/loremind/domain/licensing/License.java b/core/src/main/java/com/loremind/domain/licensing/License.java new file mode 100644 index 0000000..5988386 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/licensing/License.java @@ -0,0 +1,48 @@ +package com.loremind.domain.licensing; + +import lombok.Builder; +import lombok.Data; + +import java.time.Instant; + +/** + * Licence Patreon installee dans cette instance LoreMind. + *

+ * Singleton (une seule licence par instance, identifiee logiquement par + * {@code id = "current"}). Contient le JWT brut emis par le relais OAuth + * + les claims extraits a la verification, plus l'etat operationnel + * (derniere tentative de refresh, succes/echec). + *

+ * Note securite : {@link #rawJwt} est stocke tel quel ; sa signature + * Ed25519 est verifiee a chaque lecture. Pas besoin de chiffrement au repos + * supplementaire — un attaquant qui a acces a la base a deja l'instance, + * et le JWT ne donne aucun pouvoir au-dela du canal beta de cette instance. + */ +@Data +@Builder +public class License { + + private String id; + + private String rawJwt; + + private String patreonUserId; + + private String tierId; + + private String instanceId; + + private Instant issuedAt; + + private Instant expiresAt; + + private Instant lastRefreshAttemptAt; + + private boolean lastRefreshSucceeded; + + private boolean betaChannelEnabled; + + private Instant createdAt; + + private Instant updatedAt; +} diff --git a/core/src/main/java/com/loremind/domain/licensing/LicenseClaims.java b/core/src/main/java/com/loremind/domain/licensing/LicenseClaims.java new file mode 100644 index 0000000..423097e --- /dev/null +++ b/core/src/main/java/com/loremind/domain/licensing/LicenseClaims.java @@ -0,0 +1,15 @@ +package com.loremind.domain.licensing; + +import java.time.Instant; + +/** + * Claims extraits d'un JWT licence apres verification de signature. + * Immuable. + */ +public record LicenseClaims( + String subject, + String tierId, + String instanceId, + Instant issuedAt, + Instant expiresAt +) {} diff --git a/core/src/main/java/com/loremind/domain/licensing/LicenseSnapshot.java b/core/src/main/java/com/loremind/domain/licensing/LicenseSnapshot.java new file mode 100644 index 0000000..d79b90c --- /dev/null +++ b/core/src/main/java/com/loremind/domain/licensing/LicenseSnapshot.java @@ -0,0 +1,23 @@ +package com.loremind.domain.licensing; + +import java.time.Instant; + +/** + * Vue immuable de la licence pour exposition vers les couches superieures. + * Decouple le domaine du DTO web et permet de calculer le {@link LicenseStatus} + * a un instant donne sans muter l'entite. + */ +public record LicenseSnapshot( + LicenseStatus status, + String patreonUserId, + String tierId, + String instanceId, + Instant expiresAt, + Instant lastRefreshAttemptAt, + boolean lastRefreshSucceeded, + boolean betaChannelEnabled +) { + public static LicenseSnapshot none() { + return new LicenseSnapshot(LicenseStatus.NONE, null, null, null, null, null, false, false); + } +} diff --git a/core/src/main/java/com/loremind/domain/licensing/LicenseStatus.java b/core/src/main/java/com/loremind/domain/licensing/LicenseStatus.java new file mode 100644 index 0000000..d410a14 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/licensing/LicenseStatus.java @@ -0,0 +1,23 @@ +package com.loremind.domain.licensing; + +/** + * Etat operationnel de la licence vis-a-vis de l'acces beta. + *

+ * Calcule a partir de la presence de licence + son JWT exp + grace period. + *

    + *
  • {@link #NONE} : aucune licence installee
  • + *
  • {@link #VALID} : JWT non expire, acces beta autorise
  • + *
  • {@link #GRACE} : JWT expire mais dans la periode de tolerance ; + * acces beta toujours autorise, l'UI doit avertir
  • + *
  • {@link #EXPIRED} : au-dela de la grace period, acces beta refuse
  • + *
  • {@link #UNVERIFIABLE} : JWT impossible a verifier (cle publique manquante, + * signature invalide, claims malformes) — traite comme NONE pour la securite
  • + *
+ */ +public enum LicenseStatus { + NONE, + VALID, + GRACE, + EXPIRED, + UNVERIFIABLE +} diff --git a/core/src/main/java/com/loremind/domain/licensing/RegistryCredentials.java b/core/src/main/java/com/loremind/domain/licensing/RegistryCredentials.java new file mode 100644 index 0000000..72a38c4 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/licensing/RegistryCredentials.java @@ -0,0 +1,18 @@ +package com.loremind.domain.licensing; + +import java.time.Instant; + +/** + * Credentials de pull pour un registry Docker, distribues par le relais + * apres verification d'un JWT licence valide. + *

+ * {@code expiresAt} peut etre {@code null} si le credential est statique + * (cas du PAT GHCR partage en MVP) ; sinon, l'instance doit re-demander + * de nouveaux credentials avant cette date. + */ +public record RegistryCredentials( + String registry, + String username, + String password, + Instant expiresAt +) {} diff --git a/core/src/main/java/com/loremind/domain/licensing/ports/DockerConfigWriter.java b/core/src/main/java/com/loremind/domain/licensing/ports/DockerConfigWriter.java new file mode 100644 index 0000000..c750cfa --- /dev/null +++ b/core/src/main/java/com/loremind/domain/licensing/ports/DockerConfigWriter.java @@ -0,0 +1,34 @@ +package com.loremind.domain.licensing.ports; + +import com.loremind.domain.licensing.RegistryCredentials; + +import java.io.IOException; + +/** + * Port de sortie : ecriture du docker config.json partage avec Watchtower. + *

+ * Le fichier sert a Watchtower pour s'authentifier au registry prive (GHCR) + * lors du pull des images du canal beta. Volume Docker {@code docker-config} + * monte sur Core (en ecriture) et sur Watchtower (en lecture, via la variable + * {@code DOCKER_CONFIG}). + */ +public interface DockerConfigWriter { + + /** + * Ecrit ou met a jour les credentials pour le registry indique. + * Cree le fichier s'il n'existe pas, conserve les autres registries deja + * presents (en theorie : aucun, mais defensif). + */ + void writeCredentials(RegistryCredentials credentials) throws IOException; + + /** + * Supprime le fichier de credentials. Appele quand la licence est invalidee + * ou que le toggle beta passe a OFF. + */ + void clear() throws IOException; + + /** + * @return true si le fichier de creds existe actuellement. + */ + boolean isPresent(); +} diff --git a/core/src/main/java/com/loremind/domain/licensing/ports/JwtVerifier.java b/core/src/main/java/com/loremind/domain/licensing/ports/JwtVerifier.java new file mode 100644 index 0000000..f8d800f --- /dev/null +++ b/core/src/main/java/com/loremind/domain/licensing/ports/JwtVerifier.java @@ -0,0 +1,34 @@ +package com.loremind.domain.licensing.ports; + +import com.loremind.domain.licensing.LicenseClaims; + +/** + * Port de sortie : verification de signature et extraction des claims + * d'un JWT emis par le relais. + *

+ * Implemente cote infrastructure avec la cle publique Ed25519 embarquee + * (SPKI PEM via configuration {@code licensing.jwt.public-key}). + */ +public interface JwtVerifier { + + /** + * Verifie la signature, l'issuer, l'audience et l'expiration du JWT. + * @throws JwtVerificationException si la signature est invalide ou les claims malformes + */ + LicenseClaims verify(String rawJwt) throws JwtVerificationException; + + /** + * @return true si la cle publique est configuree et utilisable. + * Permet a l'application de masquer la feature licensing si pas configuree. + */ + boolean isConfigured(); + + class JwtVerificationException extends Exception { + public JwtVerificationException(String message) { + super(message); + } + public JwtVerificationException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/core/src/main/java/com/loremind/domain/licensing/ports/LicenseRelay.java b/core/src/main/java/com/loremind/domain/licensing/ports/LicenseRelay.java new file mode 100644 index 0000000..f9387f5 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/licensing/ports/LicenseRelay.java @@ -0,0 +1,59 @@ +package com.loremind.domain.licensing.ports; + +import com.loremind.domain.licensing.RegistryCredentials; + +/** + * Port de sortie vers le service relais OAuth Patreon. + * Encapsule les appels HTTP : refresh JWT et fetch registry credentials. + */ +public interface LicenseRelay { + + /** + * Demande au relais l'URL OAuth a ouvrir pour connecter le compte Patreon. + */ + String buildConnectUrl(String instanceId); + + /** + * Demande au relais de renouveler un JWT existant. Le relais re-verifie + * le tier Patreon de l'utilisateur ; renvoie un nouveau JWT si toujours + * actif, ou leve {@link RelayException} sinon. + */ + String refreshToken(String currentJwt) throws RelayException; + + /** + * Demande au relais les credentials de pull du registry beta. + */ + RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException; + + /** + * Erreurs distinctes emises par le relais. Permet au service application + * de differencier "tier expire" (action utilisateur) de "relais down" + * (action transitoire, garde la grace period). + */ + class RelayException extends Exception { + private final RelayErrorKind kind; + + public RelayException(RelayErrorKind kind, String message) { + super(message); + this.kind = kind; + } + + public RelayException(RelayErrorKind kind, String message, Throwable cause) { + super(message, cause); + this.kind = kind; + } + + public RelayErrorKind getKind() { + return kind; + } + } + + enum RelayErrorKind { + /** Le relais est joignable mais refuse : tier non actif, JWT trop ancien, etc. */ + REJECTED, + /** Le relais a renvoye un JWT mais il est invalide / non parsable. */ + BAD_RESPONSE, + /** Le relais est injoignable / 5xx / timeout. */ + TRANSIENT + } +} diff --git a/core/src/main/java/com/loremind/domain/licensing/ports/LicenseRepository.java b/core/src/main/java/com/loremind/domain/licensing/ports/LicenseRepository.java new file mode 100644 index 0000000..d760868 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/licensing/ports/LicenseRepository.java @@ -0,0 +1,19 @@ +package com.loremind.domain.licensing.ports; + +import com.loremind.domain.licensing.License; + +import java.util.Optional; + +/** + * Port de sortie pour la persistance de la licence installee. + *

+ * Une seule licence par instance ({@code id = "current"} par convention). + */ +public interface LicenseRepository { + + Optional findCurrent(); + + License save(License license); + + void deleteCurrent(); +} diff --git a/core/src/main/java/com/loremind/infrastructure/licensing/FileDockerConfigWriter.java b/core/src/main/java/com/loremind/infrastructure/licensing/FileDockerConfigWriter.java new file mode 100644 index 0000000..3d2fa08 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/licensing/FileDockerConfigWriter.java @@ -0,0 +1,111 @@ +package com.loremind.infrastructure.licensing; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.loremind.domain.licensing.RegistryCredentials; +import com.loremind.domain.licensing.ports.DockerConfigWriter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Base64; + +/** + * Implementation : ecriture du fichier {@code config.json} au format Docker + * standard, dans un volume partage avec Watchtower. + *

+ * Format produit : + *

{@code
+ * {
+ *   "auths": {
+ *     "ghcr.io": {
+ *       "auth": ""
+ *     }
+ *   }
+ * }
+ * }
+ */ +@Component +public class FileDockerConfigWriter implements DockerConfigWriter { + + private static final Logger log = LoggerFactory.getLogger(FileDockerConfigWriter.class); + + private final Path configPath; + private final ObjectMapper mapper = new ObjectMapper(); + + public FileDockerConfigWriter( + @Value("${licensing.docker-config-path:/shared/docker/config.json}") String pathStr) { + this.configPath = Path.of(pathStr); + } + + @Override + public void writeCredentials(RegistryCredentials credentials) throws IOException { + ensureParentDirectory(); + + ObjectNode root; + if (Files.exists(configPath)) { + try { + JsonNode existing = mapper.readTree(configPath.toFile()); + root = existing.isObject() ? (ObjectNode) existing : mapper.createObjectNode(); + } catch (IOException e) { + log.warn("Existing docker config unreadable, overwriting: {}", e.getMessage()); + root = mapper.createObjectNode(); + } + } else { + root = mapper.createObjectNode(); + } + + ObjectNode auths = root.has("auths") && root.get("auths").isObject() + ? (ObjectNode) root.get("auths") + : root.putObject("auths"); + + String b64 = Base64.getEncoder().encodeToString( + (credentials.username() + ":" + credentials.password()).getBytes(StandardCharsets.UTF_8)); + + ObjectNode entry = mapper.createObjectNode(); + entry.put("auth", b64); + auths.set(credentials.registry(), entry); + + Files.writeString(configPath, mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root), + StandardCharsets.UTF_8); + applyRestrictivePermissions(); + log.info("Docker config written at {} for registry {}", configPath, credentials.registry()); + } + + @Override + public void clear() throws IOException { + if (Files.exists(configPath)) { + Files.delete(configPath); + log.info("Docker config cleared at {}", configPath); + } + } + + @Override + public boolean isPresent() { + return Files.exists(configPath); + } + + private void ensureParentDirectory() throws IOException { + Path parent = configPath.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + } + + /** 0600 sur POSIX. Sur Windows (dev), no-op silencieux. */ + private void applyRestrictivePermissions() { + try { + Files.setPosixFilePermissions(configPath, PosixFilePermissions.fromString("rw-------")); + } catch (UnsupportedOperationException | IOException e) { + // Windows / FS qui ne supporte pas POSIX => ignore (le conteneur tourne sous Linux en prod) + } + } + +} diff --git a/core/src/main/java/com/loremind/infrastructure/licensing/HttpLicenseRelay.java b/core/src/main/java/com/loremind/infrastructure/licensing/HttpLicenseRelay.java new file mode 100644 index 0000000..fa98958 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/licensing/HttpLicenseRelay.java @@ -0,0 +1,146 @@ +package com.loremind.infrastructure.licensing; + +import com.fasterxml.jackson.databind.JsonNode; +import com.loremind.domain.licensing.RegistryCredentials; +import com.loremind.domain.licensing.ports.LicenseRelay; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +/** + * Client HTTP du relais OAuth Patreon (deploye sur Cloudflare Workers). + * Voir {@code relay/} pour le code du relais. + */ +@Component +public class HttpLicenseRelay implements LicenseRelay { + + private static final Logger log = LoggerFactory.getLogger(HttpLicenseRelay.class); + + private final RestTemplate http; + private final String baseUrl; + + public HttpLicenseRelay( + RestTemplateBuilder builder, + @Value("${licensing.relay.base-url:}") String baseUrl) { + this.http = builder + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(15)) + .build(); + this.baseUrl = stripTrailingSlash(baseUrl); + } + + @Override + public String buildConnectUrl(String instanceId) { + if (baseUrl.isBlank()) { + throw new IllegalStateException("Licensing relay base URL not configured"); + } + String encoded = URLEncoder.encode(instanceId, StandardCharsets.UTF_8); + return baseUrl + "/oauth/start?instance_id=" + encoded; + } + + @Override + public String refreshToken(String currentJwt) throws RelayException { + if (baseUrl.isBlank()) { + throw new RelayException(RelayErrorKind.TRANSIENT, "relay not configured"); + } + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + Map body = Map.of("jwt", currentJwt); + + ResponseEntity resp; + try { + resp = http.exchange( + baseUrl + "/token/refresh", + HttpMethod.POST, + new HttpEntity<>(body, headers), + JsonNode.class); + } catch (HttpClientErrorException e) { + throw new RelayException(RelayErrorKind.REJECTED, + "relay rejected refresh: " + e.getStatusCode() + " " + e.getStatusText()); + } catch (HttpServerErrorException e) { + throw new RelayException(RelayErrorKind.TRANSIENT, + "relay 5xx: " + e.getStatusCode()); + } catch (RestClientException e) { + throw new RelayException(RelayErrorKind.TRANSIENT, "relay unreachable: " + e.getMessage(), e); + } + + JsonNode payload = resp.getBody(); + if (payload == null || !payload.hasNonNull("jwt")) { + throw new RelayException(RelayErrorKind.BAD_RESPONSE, "missing jwt in refresh response"); + } + return payload.get("jwt").asText(); + } + + @Override + public RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException { + if (baseUrl.isBlank()) { + throw new RelayException(RelayErrorKind.TRANSIENT, "relay not configured"); + } + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + Map body = Map.of("jwt", currentJwt); + + ResponseEntity resp; + try { + resp = http.exchange( + baseUrl + "/registry/credentials", + HttpMethod.POST, + new HttpEntity<>(body, headers), + JsonNode.class); + } catch (HttpClientErrorException e) { + throw new RelayException(RelayErrorKind.REJECTED, + "relay rejected creds: " + e.getStatusCode() + " " + e.getStatusText()); + } catch (HttpServerErrorException e) { + throw new RelayException(RelayErrorKind.TRANSIENT, + "relay 5xx: " + e.getStatusCode()); + } catch (RestClientException e) { + throw new RelayException(RelayErrorKind.TRANSIENT, "relay unreachable: " + e.getMessage(), e); + } + + JsonNode payload = resp.getBody(); + if (payload == null + || !payload.hasNonNull("registry") + || !payload.hasNonNull("username") + || !payload.hasNonNull("password")) { + throw new RelayException(RelayErrorKind.BAD_RESPONSE, "incomplete credentials response"); + } + Instant expiresAt = null; + if (payload.hasNonNull("expires_at")) { + try { + expiresAt = Instant.parse(payload.get("expires_at").asText()); + } catch (Exception e) { + log.warn("Cannot parse expires_at from relay creds response: {}", e.getMessage()); + } + } + return new RegistryCredentials( + payload.get("registry").asText(), + payload.get("username").asText(), + payload.get("password").asText(), + expiresAt + ); + } + + private static String stripTrailingSlash(String s) { + if (s == null) return ""; + String v = s.trim(); + if (v.endsWith("/")) v = v.substring(0, v.length() - 1); + return v; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/licensing/LicenseRefreshDaemon.java b/core/src/main/java/com/loremind/infrastructure/licensing/LicenseRefreshDaemon.java new file mode 100644 index 0000000..d21e719 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/licensing/LicenseRefreshDaemon.java @@ -0,0 +1,93 @@ +package com.loremind.infrastructure.licensing; + +import com.loremind.application.licensing.LicenseService; +import com.loremind.domain.licensing.LicenseSnapshot; +import com.loremind.domain.licensing.LicenseStatus; +import com.loremind.domain.licensing.RegistryCredentials; +import com.loremind.domain.licensing.ports.DockerConfigWriter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Optional; + +/** + * Daemon planifie qui : + *
    + *
  • renouvelle le JWT licence via le relais avant expiration (J-2)
  • + *
  • met a jour les credentials registry GHCR pour Watchtower + * (volume partage docker-config) tant que le canal beta est ON
  • + *
  • nettoie les credentials si la licence est invalidee ou le toggle OFF
  • + *
+ * Idempotent : peut tourner toutes les 6h sans risque, fait du no-op + * la plupart du temps. + */ +@Component +public class LicenseRefreshDaemon { + + private static final Logger log = LoggerFactory.getLogger(LicenseRefreshDaemon.class); + + /** 6 heures entre chaque cycle. Suffisant pour rattraper un J-2 sans surcharger. */ + private static final long FIXED_DELAY_MS = 6L * 60L * 60L * 1000L; + /** Premier run apres 30s pour laisser le contexte Spring se stabiliser. */ + private static final long INITIAL_DELAY_MS = 30_000L; + + private final LicenseService licenseService; + private final DockerConfigWriter dockerConfigWriter; + + public LicenseRefreshDaemon(LicenseService licenseService, + DockerConfigWriter dockerConfigWriter) { + this.licenseService = licenseService; + this.dockerConfigWriter = dockerConfigWriter; + } + + @Scheduled(initialDelay = INITIAL_DELAY_MS, fixedDelay = FIXED_DELAY_MS) + public void tick() { + if (!licenseService.isLicensingEnabled()) { + return; + } + try { + licenseService.refreshIfNeeded(); + syncDockerConfig(); + } catch (Exception e) { + log.error("LicenseRefreshDaemon tick failed: {}", e.getMessage(), e); + } + } + + /** + * Aligne le fichier docker config avec l'etat de la licence et le toggle : + *
    + *
  • VALID/GRACE + beta ON -> ecrit/refresh les creds
  • + *
  • tout autre cas -> efface le fichier
  • + *
+ */ + private void syncDockerConfig() { + LicenseSnapshot snap = licenseService.getCurrentSnapshot(); + boolean shouldHaveCreds = snap.betaChannelEnabled() + && (snap.status() == LicenseStatus.VALID || snap.status() == LicenseStatus.GRACE); + + if (!shouldHaveCreds) { + try { + if (dockerConfigWriter.isPresent()) { + dockerConfigWriter.clear(); + } + } catch (IOException e) { + log.warn("Cannot clear docker config: {}", e.getMessage()); + } + return; + } + + Optional creds = licenseService.fetchRegistryCredentials(); + if (creds.isEmpty()) { + log.warn("Beta enabled but cannot fetch registry credentials (relay down or rejected)"); + return; + } + try { + dockerConfigWriter.writeCredentials(creds.get()); + } catch (IOException e) { + log.error("Cannot write docker config: {}", e.getMessage()); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/licensing/NimbusJwtVerifier.java b/core/src/main/java/com/loremind/infrastructure/licensing/NimbusJwtVerifier.java new file mode 100644 index 0000000..d478145 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/licensing/NimbusJwtVerifier.java @@ -0,0 +1,188 @@ +package com.loremind.infrastructure.licensing; + +import com.loremind.domain.licensing.LicenseClaims; +import com.loremind.domain.licensing.ports.JwtVerifier; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.Ed25519Verifier; +import com.nimbusds.jose.jwk.OctetKeyPair; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.bouncycastle.asn1.ASN1Sequence; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.util.Base64; +import java.util.Date; + +/** + * Verifie les JWT EdDSA/Ed25519 emis par le relais Patreon. + *

+ * La cle publique est fournie en PEM SPKI via la propriete + * {@code licensing.jwt.public-key} (env {@code LICENSING_JWT_PUBLIC_KEY}). + * Si la cle est absente ou invalide, {@link #isConfigured()} retourne false + * et {@link #verify} echoue systematiquement — la feature licensing est + * desactivee silencieusement. + */ +@Component +public class NimbusJwtVerifier implements JwtVerifier { + + private static final Logger log = LoggerFactory.getLogger(NimbusJwtVerifier.class); + + private final String expectedIssuer; + private final String expectedAudience; + private final OctetKeyPair publicKey; + + public NimbusJwtVerifier( + @Value("${licensing.jwt.public-key:}") String publicKeyPemFromEnv, + @Value("${licensing.jwt.expected-issuer:loremind-auth}") String expectedIssuer, + @Value("${licensing.jwt.expected-audience:loremind-instance}") String expectedAudience) { + this.expectedIssuer = expectedIssuer; + this.expectedAudience = expectedAudience; + // Strategie : env var en priorite (rotation possible sans rebuild), + // sinon ressource classpath embarquee dans le binaire. + String pem = (publicKeyPemFromEnv != null && !publicKeyPemFromEnv.isBlank()) + ? publicKeyPemFromEnv + : loadEmbeddedKey(); + this.publicKey = parsePemSpki(pem); + if (publicKey == null) { + log.info("Licensing JWT verifier disabled (no public key found)"); + } else { + String source = (publicKeyPemFromEnv != null && !publicKeyPemFromEnv.isBlank()) ? "env" : "embedded"; + log.info("Licensing JWT verifier enabled (issuer={}, audience={}, key source={})", + expectedIssuer, expectedAudience, source); + } + } + + /** + * Charge la cle publique embarquee dans le binaire (resource classpath). + * Le fichier est un PEM SPKI standard, fourni a la build pour chaque + * release. Si absent, la feature licensing est desactivee. + */ + private static String loadEmbeddedKey() { + ClassPathResource resource = new ClassPathResource("licensing/jwt-public-key.pem"); + if (!resource.exists()) { + return null; + } + try (InputStream in = resource.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + log.warn("Cannot read embedded JWT public key: {}", e.getMessage()); + return null; + } + } + + @Override + public boolean isConfigured() { + return publicKey != null; + } + + @Override + public LicenseClaims verify(String rawJwt) throws JwtVerificationException { + if (publicKey == null) { + throw new JwtVerificationException("JWT verifier not configured"); + } + if (rawJwt == null || rawJwt.isBlank()) { + throw new JwtVerificationException("JWT is empty"); + } + + SignedJWT signed; + try { + signed = SignedJWT.parse(rawJwt); + } catch (ParseException e) { + throw new JwtVerificationException("JWT parse error: " + e.getMessage(), e); + } + + JWSAlgorithm alg = signed.getHeader().getAlgorithm(); + if (!JWSAlgorithm.EdDSA.equals(alg)) { + throw new JwtVerificationException("Unexpected JWT algorithm: " + alg); + } + + try { + JWSVerifier verifier = new Ed25519Verifier(publicKey); + if (!signed.verify(verifier)) { + throw new JwtVerificationException("JWT signature invalid"); + } + } catch (Exception e) { + throw new JwtVerificationException("JWT signature verification failed: " + e.getMessage(), e); + } + + JWTClaimsSet claims; + try { + claims = signed.getJWTClaimsSet(); + } catch (ParseException e) { + throw new JwtVerificationException("JWT claims parse error", e); + } + + if (!expectedIssuer.equals(claims.getIssuer())) { + throw new JwtVerificationException("JWT issuer mismatch: " + claims.getIssuer()); + } + if (claims.getAudience() == null || !claims.getAudience().contains(expectedAudience)) { + throw new JwtVerificationException("JWT audience mismatch"); + } + + Date exp = claims.getExpirationTime(); + Date iat = claims.getIssueTime(); + String sub = claims.getSubject(); + if (exp == null || iat == null || sub == null) { + throw new JwtVerificationException("JWT missing required claims"); + } + + // Note : on ne refuse pas un JWT expire ici. C'est au LicenseService + // de decider ce qu'il fait d'un JWT expire (grace period, refresh, etc.). + // La verification de signature reste valide tant que la cle existe. + + String tierId; + String instanceId; + try { + tierId = claims.getStringClaim("tier_id"); + instanceId = claims.getStringClaim("instance_id"); + } catch (ParseException e) { + throw new JwtVerificationException("JWT custom claim parse error", e); + } + if (tierId == null || tierId.isBlank() || instanceId == null || instanceId.isBlank()) { + throw new JwtVerificationException("JWT missing tier_id or instance_id"); + } + + return new LicenseClaims( + sub, + tierId, + instanceId, + iat.toInstant(), + exp.toInstant() + ); + } + + /** + * Parse une cle publique Ed25519 au format PEM SPKI vers un Nimbus + * {@link OctetKeyPair} (forme JWK utilisee pour la verification). + */ + private static OctetKeyPair parsePemSpki(String pem) { + if (pem == null || pem.isBlank()) return null; + try { + String base64 = pem + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s+", ""); + byte[] der = Base64.getDecoder().decode(base64); + SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Sequence.fromByteArray(der)); + byte[] keyBytes = spki.getPublicKeyData().getOctets(); + String x = Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes); + return new OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, com.nimbusds.jose.util.Base64URL.from(x)) + .build(); + } catch (IOException | IllegalArgumentException e) { + log.warn("Cannot parse licensing JWT public key: {}", e.getMessage()); + return null; + } + } + +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/LicenseJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/LicenseJpaEntity.java new file mode 100644 index 0000000..22a8504 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/LicenseJpaEntity.java @@ -0,0 +1,72 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +/** + * Entite JPA pour la licence Patreon installee. + *

+ * Singleton : une seule ligne par instance (id = "current"). Ce design permet + * de ne jamais avoir de licence "fantome" en base et de simplifier les queries. + */ +@Entity +@Table(name = "licenses") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LicenseJpaEntity { + + @Id + private String id; + + @Column(name = "raw_jwt", columnDefinition = "TEXT", nullable = false) + private String rawJwt; + + @Column(name = "patreon_user_id", nullable = false) + private String patreonUserId; + + @Column(name = "tier_id", nullable = false) + private String tierId; + + @Column(name = "instance_id", nullable = false) + private String instanceId; + + @Column(name = "issued_at", nullable = false) + private Instant issuedAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "last_refresh_attempt_at") + private Instant lastRefreshAttemptAt; + + @Column(name = "last_refresh_succeeded", nullable = false) + private boolean lastRefreshSucceeded; + + @Column(name = "beta_channel_enabled", nullable = false) + private boolean betaChannelEnabled; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(); + if (createdAt == null) createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/LicenseJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/LicenseJpaRepository.java new file mode 100644 index 0000000..b0f1728 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/LicenseJpaRepository.java @@ -0,0 +1,9 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.LicenseJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LicenseJpaRepository extends JpaRepository { +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLicenseRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLicenseRepository.java new file mode 100644 index 0000000..c38462c --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresLicenseRepository.java @@ -0,0 +1,76 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.licensing.License; +import com.loremind.domain.licensing.ports.LicenseRepository; +import com.loremind.infrastructure.persistence.entity.LicenseJpaEntity; +import com.loremind.infrastructure.persistence.jpa.LicenseJpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.Optional; + +@Repository +public class PostgresLicenseRepository implements LicenseRepository { + + static final String CURRENT_ID = "current"; + + private final LicenseJpaRepository jpa; + + public PostgresLicenseRepository(LicenseJpaRepository jpa) { + this.jpa = jpa; + } + + @Override + public Optional findCurrent() { + return jpa.findById(CURRENT_ID).map(this::toDomain); + } + + @Override + public License save(License license) { + LicenseJpaEntity entity = toEntity(license); + if (entity.getCreatedAt() == null) { + entity.setCreatedAt(Instant.now()); + } + LicenseJpaEntity saved = jpa.save(entity); + return toDomain(saved); + } + + @Override + public void deleteCurrent() { + jpa.deleteById(CURRENT_ID); + } + + private License toDomain(LicenseJpaEntity e) { + return License.builder() + .id(e.getId()) + .rawJwt(e.getRawJwt()) + .patreonUserId(e.getPatreonUserId()) + .tierId(e.getTierId()) + .instanceId(e.getInstanceId()) + .issuedAt(e.getIssuedAt()) + .expiresAt(e.getExpiresAt()) + .lastRefreshAttemptAt(e.getLastRefreshAttemptAt()) + .lastRefreshSucceeded(e.isLastRefreshSucceeded()) + .betaChannelEnabled(e.isBetaChannelEnabled()) + .createdAt(e.getCreatedAt()) + .updatedAt(e.getUpdatedAt()) + .build(); + } + + private LicenseJpaEntity toEntity(License l) { + return LicenseJpaEntity.builder() + .id(CURRENT_ID) + .rawJwt(l.getRawJwt()) + .patreonUserId(l.getPatreonUserId()) + .tierId(l.getTierId()) + .instanceId(l.getInstanceId()) + .issuedAt(l.getIssuedAt()) + .expiresAt(l.getExpiresAt()) + .lastRefreshAttemptAt(l.getLastRefreshAttemptAt()) + .lastRefreshSucceeded(l.isLastRefreshSucceeded()) + .betaChannelEnabled(l.isBetaChannelEnabled()) + .createdAt(l.getCreatedAt()) + .updatedAt(l.getUpdatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java b/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java index a82e6bf..c14c874 100644 --- a/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java +++ b/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java @@ -1,5 +1,9 @@ package com.loremind.infrastructure.updates; +import com.loremind.application.licensing.LicenseService; +import com.loremind.domain.licensing.LicenseSnapshot; +import com.loremind.domain.licensing.LicenseStatus; +import com.loremind.domain.licensing.RegistryCredentials; import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -10,6 +14,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; @@ -19,9 +24,11 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; /** @@ -68,6 +75,9 @@ public class UpdateCheckService { private final String tag; private final String watchtowerUrl; private final String watchtowerToken; + private final List betaImages; + private final String betaTag; + private final LicenseService licenseService; private final Map baselineDigests = new ConcurrentHashMap<>(); @@ -77,7 +87,10 @@ public class UpdateCheckService { @Value("${update-check.images:}") String imagesCsv, @Value("${update-check.tag:latest}") String tag, @Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl, - @Value("${update-check.watchtower-token:}") String watchtowerToken) { + @Value("${update-check.watchtower-token:}") String watchtowerToken, + @Value("${licensing.beta.images:}") String betaImagesCsv, + @Value("${licensing.beta.tag:latest}") String betaTag, + LicenseService licenseService) { this.http = builder .setConnectTimeout(Duration.ofSeconds(5)) .setReadTimeout(Duration.ofSeconds(15)) @@ -87,6 +100,9 @@ public class UpdateCheckService { this.tag = tag; this.watchtowerUrl = watchtowerUrl; this.watchtowerToken = watchtowerToken; + this.betaImages = parseImages(betaImagesCsv); + this.betaTag = betaTag; + this.licenseService = licenseService; } /** Backoff progressif (ms) pour retry de baseline en cas d'echec initial. */ @@ -187,6 +203,118 @@ public class UpdateCheckService { return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now()); } + /** + * Verifie l'etat du canal beta (images privees GHCR). + * Necessite licence valide/grace + toggle beta ON. + * Authentification basic auth via le PAT distribue par le relais. + * + * @return statut beta (peut etre {@link BetaStatus#disabled()} si licence absente, + * beta off ou licence expiree) + */ + public BetaStatus checkBeta() { + if (!licenseService.isLicensingEnabled()) { + return BetaStatus.disabled("licensing-not-configured"); + } + LicenseSnapshot snap = licenseService.getCurrentSnapshot(); + if (snap.status() != LicenseStatus.VALID && snap.status() != LicenseStatus.GRACE) { + return BetaStatus.disabled("license-" + snap.status().name().toLowerCase()); + } + if (!snap.betaChannelEnabled()) { + return BetaStatus.disabled("beta-toggle-off"); + } + if (betaImages.isEmpty()) { + return BetaStatus.disabled("no-beta-images-configured"); + } + + Optional creds = licenseService.fetchRegistryCredentials(); + if (creds.isEmpty()) { + return new BetaStatus(true, false, true, List.of(), Instant.now(), "relay-unavailable"); + } + + String basicAuth = "Basic " + Base64.getEncoder().encodeToString( + (creds.get().username() + ":" + creds.get().password()).getBytes(StandardCharsets.UTF_8)); + String betaRegistry = normalizeRegistry(creds.get().registry()); + + List statuses = new ArrayList<>(); + boolean anyUpdate = false; + boolean anyUnknown = false; + for (String image : betaImages) { + String remote = null; + try { + remote = fetchRemoteDigestAuth(betaRegistry, image, betaTag, basicAuth); + } catch (Exception e) { + log.warn("Beta check failed for {}: {}", image, e.getMessage()); + } + // Pas de baseline pour la beta : on ne peut pas dire "a jour" car on + // ne sait pas quelle version le user fait tourner. On expose juste le + // digest remote ; l'UI affichera "version disponible : " sans + // comparaison locale tant qu'il n'y a pas un mecanisme de baseline. + ImageStatusKind kind = (remote == null) ? ImageStatusKind.UNKNOWN : ImageStatusKind.UPDATE_AVAILABLE; + if (kind == ImageStatusKind.UNKNOWN) anyUnknown = true; + else anyUpdate = true; + statuses.add(new ImageStatus(image, null, remote, kind)); + } + return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null); + } + + private String fetchRemoteDigestAuth(String registryUrl, String image, String tagName, String authHeader) { + String url = registryUrl + "/v2/" + image + "/manifests/" + tagName; + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(MANIFEST_ACCEPT); + headers.set(HttpHeaders.AUTHORIZATION, authHeader); + try { + return digestCall(url, headers); + } catch (HttpClientErrorException.Unauthorized e) { + // GHCR peut exiger d'echanger basic auth contre un bearer token via + // le challenge WWW-Authenticate. On reuse la logique existante en + // ajoutant l'auth header a la requete /token. + String www = e.getResponseHeaders() == null ? null + : e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE); + String token = obtainBearerTokenWithAuth(www, authHeader); + if (token == null) return null; + HttpHeaders bearerHeaders = new HttpHeaders(); + bearerHeaders.setAccept(MANIFEST_ACCEPT); + bearerHeaders.setBearerAuth(token); + return digestCall(url, bearerHeaders); + } + } + + @SuppressWarnings("rawtypes") + private String obtainBearerTokenWithAuth(@Nullable String wwwAuth, String authHeader) { + if (wwwAuth == null) return null; + String prefix = "Bearer "; + if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null; + Map params = parseAuthParams(wwwAuth.substring(prefix.length())); + String realm = params.get("realm"); + if (realm == null) return null; + StringBuilder url = new StringBuilder(realm); + boolean hasQuery = realm.contains("?"); + for (String key : new String[]{"service", "scope"}) { + String v = params.get(key); + if (v != null) { + String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8) + .replace("%3A", ":") + .replace("%2F", "/"); + url.append(hasQuery ? '&' : '?').append(key).append('=').append(encoded); + hasQuery = true; + } + } + try { + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.AUTHORIZATION, authHeader); + ResponseEntity resp = http.exchange(url.toString(), HttpMethod.GET, + new HttpEntity<>(headers), Map.class); + Map body = resp.getBody(); + if (body == null) return null; + Object t = body.get("token"); + if (t == null) t = body.get("access_token"); + return t == null ? null : t.toString(); + } catch (Exception e) { + log.warn("Beta bearer token request failed: {}", e.getMessage()); + return null; + } + } + public void apply() { if (!isEnabled()) { throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)"); @@ -348,6 +476,29 @@ public class UpdateCheckService { List images, Instant checkedAt) {} + /** + * Etat du canal beta. + *

    + *
  • {@code enabled} : true si le canal beta est actif et la licence valide.
  • + *
  • {@code disabledReason} : si {@code enabled=false}, raison technique + * (licensing-not-configured, license-none, license-expired, beta-toggle-off, + * no-beta-images-configured, relay-unavailable). Permet a l'UI d'afficher + * un message contextuel.
  • + *
+ */ + public record BetaStatus( + boolean enabled, + boolean updateAvailable, + boolean anyUnknown, + List images, + Instant checkedAt, + String disabledReason) { + + public static BetaStatus disabled(String reason) { + return new BetaStatus(false, false, false, List.of(), Instant.now(), reason); + } + } + /** * Le champ {@code updateAvailable} est conserve pour la compatibilite * avec les anciens clients ; il est strictement derive de {@code status} diff --git a/core/src/main/java/com/loremind/infrastructure/web/config/SecurityConfig.java b/core/src/main/java/com/loremind/infrastructure/web/config/SecurityConfig.java index 855b7e4..5825623 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/config/SecurityConfig.java +++ b/core/src/main/java/com/loremind/infrastructure/web/config/SecurityConfig.java @@ -67,6 +67,7 @@ public class SecurityConfig { .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/settings/**").hasRole("ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN") + .requestMatchers("/api/license/**").hasRole("ADMIN") .anyRequest().permitAll() ) .httpBasic(basic -> {}); diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/LicenseController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/LicenseController.java new file mode 100644 index 0000000..fc291ca --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/LicenseController.java @@ -0,0 +1,87 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.licensing.LicenseService; +import com.loremind.application.licensing.LicenseService.InstallException; +import com.loremind.domain.licensing.LicenseSnapshot; +import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * Endpoints de gestion de la licence Patreon. + * + *
    + *
  • {@code GET /api/license} : etat courant (status, tier, expiration...)
  • + *
  • {@code GET /api/license/connect-url} : URL OAuth a ouvrir dans le navigateur
  • + *
  • {@code POST /api/license/install} : colle un JWT recu du relais
  • + *
  • {@code DELETE /api/license} : deconnecte Patreon (efface la licence)
  • + *
  • {@code POST /api/license/refresh} : force un refresh manuel
  • + *
  • {@code PUT /api/license/beta-channel} : active/desactive le canal beta
  • + *
+ */ +@RestController +@RequestMapping("/api/license") +public class LicenseController { + + private final LicenseService licenseService; + + public LicenseController(LicenseService licenseService) { + this.licenseService = licenseService; + } + + @GetMapping + public LicenseStatusDTO getStatus() { + boolean enabled = licenseService.isLicensingEnabled(); + LicenseSnapshot snap = licenseService.getCurrentSnapshot(); + return LicenseStatusDTO.from(enabled, snap); + } + + @GetMapping("/connect-url") + public Map getConnectUrl() { + return Map.of("url", licenseService.buildConnectUrl()); + } + + @PostMapping("/install") + public ResponseEntity install(@RequestBody InstallRequest request) { + if (request == null || request.jwt() == null || request.jwt().isBlank()) { + return ResponseEntity.badRequest().body(Map.of("error", "missing jwt")); + } + try { + LicenseSnapshot snap = licenseService.installToken(request.jwt()); + return ResponseEntity.ok(LicenseStatusDTO.from(true, snap)); + } catch (InstallException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + @DeleteMapping + public ResponseEntity disconnect() { + licenseService.disconnect(); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/refresh") + public ResponseEntity refresh() { + licenseService.forceRefresh(); + boolean enabled = licenseService.isLicensingEnabled(); + return ResponseEntity.ok(LicenseStatusDTO.from(enabled, licenseService.getCurrentSnapshot())); + } + + @PutMapping("/beta-channel") + public ResponseEntity setBetaChannel(@RequestBody BetaChannelRequest request) { + if (request == null) { + return ResponseEntity.badRequest().body(Map.of("error", "missing body")); + } + try { + LicenseSnapshot snap = licenseService.setBetaChannelEnabled(request.enabled()); + return ResponseEntity.ok(LicenseStatusDTO.from(true, snap)); + } catch (IllegalStateException e) { + return ResponseEntity.status(409).body(Map.of("error", e.getMessage())); + } + } + + public record InstallRequest(String jwt) {} + public record BetaChannelRequest(boolean enabled) {} +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/UpdatesController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/UpdatesController.java index 9871673..90b3718 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/UpdatesController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/UpdatesController.java @@ -1,6 +1,7 @@ package com.loremind.infrastructure.web.controller; import com.loremind.infrastructure.updates.UpdateCheckService; +import com.loremind.infrastructure.updates.UpdateCheckService.BetaStatus; import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,6 +45,12 @@ public class UpdatesController { return updates.check(); } + @GetMapping("/check-beta") + public BetaStatus checkBeta() { + guardDemoMode(); + return updates.checkBeta(); + } + @PostMapping("/apply") public ResponseEntity> apply() { guardDemoMode(); diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/licensing/LicenseStatusDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/licensing/LicenseStatusDTO.java new file mode 100644 index 0000000..2ec38b2 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/licensing/LicenseStatusDTO.java @@ -0,0 +1,35 @@ +package com.loremind.infrastructure.web.dto.licensing; + +import com.loremind.domain.licensing.LicenseSnapshot; + +import java.time.Instant; + +/** + * Vue serialisee de l'etat de la licence pour le frontend. + * Le {@code rawJwt} n'est volontairement JAMAIS expose. + */ +public record LicenseStatusDTO( + boolean enabled, + String status, + String patreonUserId, + String tierId, + String instanceId, + Instant expiresAt, + Instant lastRefreshAttemptAt, + Boolean lastRefreshSucceeded, + boolean betaChannelEnabled +) { + public static LicenseStatusDTO from(boolean enabled, LicenseSnapshot snap) { + return new LicenseStatusDTO( + enabled, + snap.status().name(), + snap.patreonUserId(), + snap.tierId(), + snap.instanceId(), + snap.expiresAt(), + snap.lastRefreshAttemptAt(), + snap.lastRefreshAttemptAt() != null ? snap.lastRefreshSucceeded() : null, + snap.betaChannelEnabled() + ); + } +} diff --git a/core/src/main/resources/application.properties b/core/src/main/resources/application.properties index 357eeae..77d9f89 100644 --- a/core/src/main/resources/application.properties +++ b/core/src/main/resources/application.properties @@ -68,3 +68,38 @@ update-check.images=${UPDATE_CHECK_IMAGES:} update-check.tag=${UPDATE_CHECK_TAG:latest} update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080} update-check.watchtower-token=${WATCHTOWER_TOKEN:} + +# ============================================================================ +# Licensing (canal beta gate par Patreon) +# ============================================================================ +# URL du relais OAuth Patreon (Cloudflare Workers). En prod : valeur par defaut. +licensing.relay.base-url=${LICENSING_RELAY_BASE_URL:https://loremind-auth.igmlcreation.fr} + +# Cle publique Ed25519 (PEM SPKI) qui verifie les JWT emis par le relais. +# En prod : chargee automatiquement depuis classpath:licensing/jwt-public-key.pem +# (embarquee dans le binaire). Cette propriete sert UNIQUEMENT a la rotation +# de cle ou aux tests : si LICENSING_JWT_PUBLIC_KEY est defini, il prevaut +# sur le fichier embarque. +licensing.jwt.public-key=${LICENSING_JWT_PUBLIC_KEY:} +licensing.jwt.expected-issuer=loremind-auth +licensing.jwt.expected-audience=loremind-instance + +# Periode de tolerance apres expiration du JWT pendant laquelle l'instance +# garde l'acces beta meme si le relais est indisponible pour le refresh. +licensing.grace-period-days=14 +# Avant J-N de l'expiration, le daemon tente un refresh. +licensing.refresh-before-expiry-days=2 + +# Identifiant stable de l'instance (UUID genere a la premiere connexion Patreon +# et conserve en base). Utilise dans le state OAuth + dans le JWT. +licensing.instance-id-file=${LICENSING_INSTANCE_ID_FILE:} + +# Image beta : si la licence est valide ET le toggle canal beta active, +# UpdateCheckService check ces images en plus du canal stable. +licensing.beta.images=${LICENSING_BETA_IMAGES:igmlcreation/loremind-beta-core,igmlcreation/loremind-beta-brain,igmlcreation/loremind-beta-web} +licensing.beta.tag=${LICENSING_BETA_TAG:latest} + +# Chemin de sortie pour le docker config.json partage avec Watchtower. +# Volume Docker `docker-config` monte sur ce chemin dans Core, et sur +# `/shared/docker` dans Watchtower (DOCKER_CONFIG=/shared/docker). +licensing.docker-config-path=${LICENSING_DOCKER_CONFIG_PATH:/shared/docker/config.json} diff --git a/core/src/main/resources/licensing/README.md b/core/src/main/resources/licensing/README.md new file mode 100644 index 0000000..d0e139a --- /dev/null +++ b/core/src/main/resources/licensing/README.md @@ -0,0 +1,29 @@ +# Cle publique JWT du relais OAuth Patreon + +Le fichier `jwt-public-key.pem` contient la **cle publique Ed25519** qui sert +a verifier la signature des JWT licence emis par le relais +(`loremind-auth.igmlcreation.fr`). + +## Pourquoi ici ? + +- C'est une **cle publique** : par nature non-secrete, elle peut etre committee + dans le repo public et embarquee dans le binaire distribue. +- Cela evite a chaque utilisateur final de devoir renseigner manuellement la + cle dans son `.env` au moment de l'installation. +- L'env `LICENSING_JWT_PUBLIC_KEY` peut surcharger cette valeur (utile pour + la rotation de cle sans rebuild ou pour les tests). + +## Si le fichier est absent + +La feature licensing est **desactivee silencieusement** : `LicenseService.isLicensingEnabled()` +renvoie `false`, et l'UI masque toute la section Patreon. + +## Rotation de cle + +1. Generer une nouvelle paire dans le relais : `npm run keys:generate` +2. Pousser la nouvelle cle privee : `wrangler secret put JWT_PRIVATE_KEY` +3. Remplacer `jwt-public-key.pem` ici avec la nouvelle cle publique +4. Rebuild + redeployer LoreMind (les anciens JWT seront refuses au prochain + refresh, l'utilisateur sera invite a reconnecter Patreon) +5. Optionnel : pendant la transition, supporter les deux cles en parallele + (pas implemente en MVP, peut etre ajoute si besoin operationnel) diff --git a/docker-compose.yml b/docker-compose.yml index e0e8ac6..bade05b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -94,6 +94,19 @@ services: UPDATE_CHECK_TAG: ${TAG:-latest} WATCHTOWER_URL: http://watchtower:8080 WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-} + # Licensing : la cle publique JWT est embarquee dans le binaire + # (core/src/main/resources/licensing/jwt-public-key.pem). + # LICENSING_JWT_PUBLIC_KEY est un override optionnel (rotation de cle + # sans rebuild) - non defini par defaut. + LICENSING_JWT_PUBLIC_KEY: ${LICENSING_JWT_PUBLIC_KEY:-} + LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr} + # Chemin du docker config.json partage avec Watchtower + LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json + volumes: + # Volume partage avec Watchtower : Core ecrit les credentials registry + # GHCR (recus du relais) ici, Watchtower les utilise pour pull les images + # privees du canal beta. Pas de creds = no-op. + - docker-config:/shared/docker restart: unless-stopped # Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe). @@ -169,7 +182,14 @@ services: profiles: ["autoupdate"] volumes: - /var/run/docker.sock:/var/run/docker.sock + # Volume partage avec Core : credentials registry GHCR (canal beta). + # Watchtower lit le config.json depuis DOCKER_CONFIG. + - docker-config:/shared/docker environment: + # Indique a Watchtower (et au CLI Docker embarque) ou trouver le + # config.json. Active automatiquement l'auth GHCR pour les images + # du canal beta des que Core a ecrit le fichier. + DOCKER_CONFIG: /shared/docker WATCHTOWER_LABEL_ENABLE: "true" WATCHTOWER_CLEANUP: "true" WATCHTOWER_INCLUDE_RESTARTING: "true" @@ -191,3 +211,6 @@ volumes: minio-data: brain-data: ollama-data: + # Volume partage Core <-> Watchtower : config.json Docker pour + # l'authentification au registry prive GHCR (canal beta Patreon). + docker-config: diff --git a/installers/install.ps1 b/installers/install.ps1 index a964df1..c4563ee 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -40,7 +40,7 @@ Auteur : ietm64 Licence : AGPL-3.0 Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR - Version : 0.7.2 + Version : 0.8.0 .LINK https://github.com/IGMLcreation/LoreMind diff --git a/web/package-lock.json b/web/package-lock.json index 247439a..70a3e90 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.7.2", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.7.2", + "version": "0.8.0", "dependencies": { "@angular/animations": "^17.0.0", "@angular/common": "^17.0.0", diff --git a/web/package.json b/web/package.json index b7d7465..05550b2 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.7.2", + "version": "0.8.0", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/services/license.service.ts b/web/src/app/services/license.service.ts new file mode 100644 index 0000000..5b1d4d1 --- /dev/null +++ b/web/src/app/services/license.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, catchError, of } from 'rxjs'; + +/** + * Reflet de LicenseStatus (enum cote backend). + */ +export type LicenseStatus = 'NONE' | 'VALID' | 'GRACE' | 'EXPIRED' | 'UNVERIFIABLE'; + +export interface LicenseStatusDTO { + enabled: boolean; + status: LicenseStatus; + patreonUserId: string | null; + tierId: string | null; + instanceId: string | null; + expiresAt: string | null; + lastRefreshAttemptAt: string | null; + lastRefreshSucceeded: boolean | null; + betaChannelEnabled: boolean; +} + +/** + * Reflet de UpdateCheckService.BetaStatus. + */ +export interface BetaStatusDTO { + enabled: boolean; + updateAvailable: boolean; + anyUnknown: boolean; + images: Array<{ + image: string; + localDigest: string | null; + remoteDigest: string | null; + status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN'; + updateAvailable: boolean; + }>; + checkedAt: string; + disabledReason: string | null; +} + +/** + * Service Angular pour la gestion de la licence Patreon. + * Tous les endpoints sont proteges par HTTP Basic (admin). + */ +@Injectable({ providedIn: 'root' }) +export class LicenseService { + private readonly apiUrl = '/api/license'; + private readonly authOptions = { withCredentials: true }; + + constructor(private http: HttpClient) {} + + getStatus(): Observable { + return this.http.get(this.apiUrl, this.authOptions).pipe( + catchError(() => of(null)) + ); + } + + getConnectUrl(): Observable<{ url: string } | null> { + return this.http.get<{ url: string }>(`${this.apiUrl}/connect-url`, this.authOptions).pipe( + catchError(() => of(null)) + ); + } + + install(jwt: string): Observable { + return this.http.post(`${this.apiUrl}/install`, { jwt }, this.authOptions).pipe( + catchError((err) => of({ error: err?.error?.error ?? 'Echec de l\'installation' })) + ); + } + + disconnect(): Observable { + return this.http.delete(this.apiUrl, this.authOptions).pipe( + // Convertit en boolean : true = succes, false = erreur + // (catchError plus bas masque les detail HTTP) + catchError(() => of(false as any)) + ) as unknown as Observable; + } + + refresh(): Observable { + return this.http.post(`${this.apiUrl}/refresh`, null, this.authOptions).pipe( + catchError(() => of(null)) + ); + } + + setBetaChannel(enabled: boolean): Observable { + return this.http.put(`${this.apiUrl}/beta-channel`, { enabled }, this.authOptions).pipe( + catchError(() => of(null)) + ); + } + + checkBeta(): Observable { + return this.http.get('/api/admin/updates/check-beta', this.authOptions).pipe( + catchError(() => of(null)) + ); + } +} diff --git a/web/src/app/settings/settings.component.html b/web/src/app/settings/settings.component.html index dc4d4e6..a87addb 100644 --- a/web/src/app/settings/settings.component.html +++ b/web/src/app/settings/settings.component.html @@ -221,58 +221,205 @@ - -
+ +

Mises a jour

Verifie aupres du registry Docker si une nouvelle version des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont exclus — ils sont mis a jour manuellement.

-
- -
+ + + +
+

Canal stable

-
- Feature non configuree (WATCHTOWER_TOKEN absent). -
- -
-
- - Une mise a jour est disponible. -
-
- - Verification impossible pour certaines images — voir details ci-dessous. -
-
- Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}). -
- -
    -
  • - {{ img.image }} - MAJ dispo - a jour - verification impossible -
  • -
- -
-
-
- - {{ updateMessage }} +
+ Feature non configuree (WATCHTOWER_TOKEN absent).
+ +
+
+ + Une mise a jour est disponible. +
+
+ + Verification impossible pour certaines images — voir details ci-dessous. +
+
+ Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}). +
+ +
    +
  • + {{ img.image }} + MAJ dispo + a jour + verification impossible +
  • +
+ +
+ +
+ +
+ + {{ updateMessage }} +
+
+
+ + + + +
+

+ + Canal beta — reserve aux patrons +

+

+ Soutiens LoreMind sur Patreon pour acceder aux nouvelles features en avant-premiere. + Le tier Compagnon (7€/mois) ou superieur debloque ce canal. +

+ + + +
+ +
+

+ Une nouvelle fenetre va s'ouvrir vers Patreon. Apres autorisation, copie le token affiche + et colle-le ci-dessous. +

+
+ + +
+
+ +
+
+ + {{ licenseError }} +
+
+ + + +
+ + Compte Patreon connecte. Tier {{ licenseStatus.tierId }} actif. +
+
+ + + Connexion Patreon expiree, mais acces beta maintenu pendant la periode de tolerance. + Verifie que ton abonnement Patreon est toujours actif et clique sur "Verifier maintenant". + +
+
+ + + Connexion Patreon expiree depuis trop longtemps. Reconnecte-toi pour retrouver l'acces beta. + +
+
+ + Le token installe ne peut plus etre verifie. Reconnecte-toi. +
+ +
    +
  • Tier : {{ licenseStatus.tierId }}
  • +
  • + Validite : + jusqu'au {{ formatDate(licenseStatus.expiresAt) }} + + (renouvellement dans {{ daysUntilExpiry }} jours) + +
  • +
  • + Dernier refresh : + {{ formatDate(licenseStatus.lastRefreshAttemptAt) }} + OK + echec +
  • +
+ +
+ +
+ +
+ + +
+ + +
+
Verification des images beta...
+
+ Indisponible : {{ betaStatus.disabledReason }} +
+
+
+ + Une version beta est disponible. Pour l'installer, modifie ton fichier .env : + IMAGE_NAMESPACE=igmlcreation/loremind-beta- puis + docker compose pull && docker compose up -d. +
+
+ + Verification beta impossible pour certaines images. +
+
    +
  • + {{ img.image }} + version dispo + verification impossible +
  • +
+
+
+
diff --git a/web/src/app/settings/settings.component.scss b/web/src/app/settings/settings.component.scss index 4af27b9..4fa08ac 100644 --- a/web/src/app/settings/settings.component.scss +++ b/web/src/app/settings/settings.component.scss @@ -364,3 +364,103 @@ padding: 0.15rem 0.5rem; border-radius: 3px; } + +// --- Sous-blocs canaux (stable / beta) ---------------------------------- +.channel-block { + margin-top: 16px; + + & + & { + margin-top: 28px; + padding-top: 22px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + } + + &:first-child { + margin-top: 0; + } +} + +.channel-title { + font-size: 1rem; + margin: 0 0 12px; + color: #c4b8e0; + display: flex; + align-items: center; + gap: 6px; +} + +// --- Section Patreon / canal beta --------------------------------------- +.license-info { + list-style: none; + margin: 12px 0 16px; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + + li { + font-size: 0.9rem; + padding: 6px 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 4px; + + strong { + color: #c4b8e0; + margin-right: 6px; + } + } +} + +.form-row-inline { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; +} + +.form-row-actions { + display: flex; + gap: 10px; + margin-top: 12px; + flex-wrap: wrap; +} + +.btn-secondary.btn-danger { + border-color: rgba(220, 80, 80, 0.4); + color: #ff7878; + + &:hover { + background: rgba(220, 80, 80, 0.12); + border-color: rgba(220, 80, 80, 0.6); + } +} + +.beta-status { + margin-top: 16px; + + code { + background: rgba(0, 0, 0, 0.3); + padding: 1px 5px; + border-radius: 3px; + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.85em; + } +} + +label.checkbox { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + + input[type="checkbox"] { + cursor: pointer; + } + + input[type="checkbox"]:disabled + span { + opacity: 0.5; + cursor: not-allowed; + } +} diff --git a/web/src/app/settings/settings.component.ts b/web/src/app/settings/settings.component.ts index df1635c..8054508 100644 --- a/web/src/app/settings/settings.component.ts +++ b/web/src/app/settings/settings.component.ts @@ -2,11 +2,12 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; -import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X } from 'lucide-angular'; +import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular'; import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service'; import { Subscription } from 'rxjs'; import { UpdatesService, UpdateStatus } from '../services/updates.service'; import { ConfigService } from '../services/config.service'; +import { LicenseService, LicenseStatusDTO, BetaStatusDTO } from '../services/license.service'; /** * Ecran de parametrage du LLM utilise par le Brain. @@ -37,6 +38,19 @@ export class SettingsComponent implements OnInit { readonly Trash2 = Trash2; readonly Plus = Plus; readonly X = X; + readonly Heart = Heart; + readonly Link2 = Link2; + readonly Unlink = Unlink; + + // --- Licence Patreon (canal beta) --- + licenseStatus: LicenseStatusDTO | null = null; + licenseLoading = false; + licenseError = ''; + /** Token JWT colle par l'utilisateur apres OAuth. */ + licenseJwtInput = ''; + /** Etat du canal beta (digests des images privees). */ + betaStatus: BetaStatusDTO | null = null; + betaChecking = false; // --- Pull / delete de modeles Ollama --- /** Dialog d'ajout de modele ouvert/ferme. */ @@ -105,7 +119,8 @@ export class SettingsComponent implements OnInit { private settingsService: SettingsService, private router: Router, private updatesService: UpdatesService, - public config: ConfigService + public config: ConfigService, + private licenseService: LicenseService ) {} ngOnInit(): void { @@ -113,6 +128,117 @@ export class SettingsComponent implements OnInit { if (this.config.updateCheckEnabled) { this.checkUpdates(); } + this.loadLicense(); + } + + // --- Licence Patreon --------------------------------------------------- + + loadLicense(): void { + this.licenseLoading = true; + this.licenseService.getStatus().subscribe({ + next: (s) => { + this.licenseStatus = s; + this.licenseLoading = false; + if (s?.enabled && (s.status === 'VALID' || s.status === 'GRACE') && s.betaChannelEnabled) { + this.checkBeta(); + } + }, + error: () => { this.licenseLoading = false; } + }); + } + + /** + * Ouvre la page OAuth Patreon dans une nouvelle fenetre. + * L'utilisateur copie ensuite le JWT et le colle dans l'input ci-dessous. + */ + connectPatreon(): void { + this.licenseError = ''; + this.licenseService.getConnectUrl().subscribe({ + next: (r) => { + if (!r?.url) { + this.licenseError = 'Impossible de generer l\'URL de connexion. Verifie ta config.'; + return; + } + window.open(r.url, '_blank', 'noopener'); + } + }); + } + + installLicense(): void { + const jwt = this.licenseJwtInput.trim(); + if (!jwt) { + this.licenseError = 'Colle d\'abord le token recu apres connexion Patreon.'; + return; + } + this.licenseError = ''; + this.licenseService.install(jwt).subscribe((res) => { + if ((res as any)?.error) { + this.licenseError = (res as any).error; + return; + } + this.licenseStatus = res as LicenseStatusDTO; + this.licenseJwtInput = ''; + this.successMessage = 'Compte Patreon connecte. L\'acces beta est actif.'; + if (this.licenseStatus.betaChannelEnabled) { + this.checkBeta(); + } + }); + } + + refreshLicense(): void { + this.licenseLoading = true; + this.licenseService.refresh().subscribe({ + next: (s) => { + this.licenseStatus = s; + this.licenseLoading = false; + }, + error: () => { this.licenseLoading = false; } + }); + } + + disconnectPatreon(): void { + if (!confirm('Deconnecter ton compte Patreon ? Tu perdras l\'acces au canal beta.')) return; + this.licenseService.disconnect().subscribe(() => { + this.licenseStatus = null; + this.betaStatus = null; + this.successMessage = 'Compte Patreon deconnecte.'; + this.loadLicense(); + }); + } + + toggleBetaChannel(enabled: boolean): void { + this.licenseService.setBetaChannel(enabled).subscribe({ + next: (s) => { + if (s) this.licenseStatus = s; + if (enabled) this.checkBeta(); + else this.betaStatus = null; + } + }); + } + + checkBeta(): void { + this.betaChecking = true; + this.licenseService.checkBeta().subscribe({ + next: (s) => { + this.betaStatus = s; + this.betaChecking = false; + }, + error: () => { this.betaChecking = false; } + }); + } + + /** Format human-readable des dates renvoyees par le backend. */ + formatDate(iso: string | null | undefined): string { + if (!iso) return ''; + try { return new Date(iso).toLocaleString(); } catch { return iso; } + } + + /** Nombre de jours restants avant expiration JWT (peut etre negatif). */ + get daysUntilExpiry(): number | null { + if (!this.licenseStatus?.expiresAt) return null; + const exp = new Date(this.licenseStatus.expiresAt).getTime(); + const now = Date.now(); + return Math.ceil((exp - now) / (1000 * 60 * 60 * 24)); } checkUpdates(): void {