9 Commits

Author SHA1 Message Date
efaf5a3794 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (web) (push) Successful in 1m38s
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
4fe93b5ff3 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m4s
Build & Push Images / build (core) (push) Successful in 1m31s
Build & Push Images / build (web) (push) Successful in 1m38s
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
0f2d1b1efe Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (core) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m34s
Build & Push Images / build (brain) (push) Successful in 53s
2026-04-28 19:12:09 +02:00
5ff05242a8 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Failing after 48s
Build & Push Images / build (core) (push) Failing after 1m18s
Build & Push Images / build (web) (push) Successful in 1m35s
2026-04-28 19:04:11 +02:00
b06c77a1eb Autre patch dockerfile
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-27 22:11:43 +02:00
03bc669efe Patch dockerfile bookworm a lieu de alpine pour corriger le problème de build
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Failing after 1m48s
2026-04-27 21:56:04 +02:00
c3873ddd84 Patch dockerfile pour ne plus que le build plante
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 1m8s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Failing after 1m42s
2026-04-27 21:43:13 +02:00
d7ceeac1b0 Correction package-lock
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 57s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build (web) (push) Failing after 1m41s
2026-04-27 19:17:01 +02:00
cdbd3cd9b4 Modification lors de la création d'élément de campagne : quand on créer un nouvel élément, on arrive sur la modification et non le résumé de l'élément
Some checks failed
E2E Tests / e2e (push) Failing after 23s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m33s
Build & Push Images / build (web) (push) Failing after 1m39s
2026-04-27 19:03:58 +02:00
49 changed files with 2683 additions and 319 deletions

10
.gitignore vendored
View File

@@ -7,6 +7,11 @@
brain/data/settings.json brain/data/settings.json
*.key *.key
*.pem *.pem
# Exception : la cle PUBLIQUE JWT du relais Patreon est destinee a etre
# embarquee dans le binaire. Pas de risque a la committer (c'est une cle
# publique par construction). Sans cette exception, le module licensing
# est silencieusement desactive dans les builds CI.
!core/src/main/resources/licensing/jwt-public-key.pem
# ============================================================================ # ============================================================================
# Java / Spring Boot / Maven # Java / Spring Boot / Maven
@@ -97,3 +102,8 @@ loremind-docs/
# Docker Compose override (dev uniquement, non-distribue aux end users) # Docker Compose override (dev uniquement, non-distribue aux end users)
# ============================================================================ # ============================================================================
docker-compose.override.yml docker-compose.override.yml
# ============================================================================
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
# ============================================================================
relay/

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.7.1</version> <version>0.8.1</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -83,6 +83,19 @@
<artifactId>minio</artifactId> <artifactId>minio</artifactId>
<version>8.5.11</version> <version>8.5.11</version>
</dependency> </dependency>
<!-- Nimbus JOSE+JWT — verification des JWT Ed25519 (EdDSA) emis par le relais
Patreon. Supporte nativement les cles Ed25519 via BouncyCastle. -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.40</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -98,6 +111,16 @@
</exclude> </exclude>
</excludes> </excludes>
</configuration> </configuration>
<executions>
<!-- Genere META-INF/build-info.properties (project.version)
consomme par Spring BuildProperties pour exposer la
version courante a l'application (UpdateCheckService). -->
<execution>
<goals>
<goal>build-info</goal>
</goals>
</execution>
</executions>
</plugin> </plugin>
<!-- JaCoCo : rapport de couverture des tests unitaires. <!-- JaCoCo : rapport de couverture des tests unitaires.

View File

@@ -2,12 +2,14 @@ package com.loremind;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* Classe principale de l'application LoreMind. * Classe principale de l'application LoreMind.
* Point d'entrée Spring Boot qui démarre l'application. * Point d'entrée Spring Boot qui démarre l'application.
*/ */
@SpringBootApplication @SpringBootApplication
@EnableScheduling
public class LoreMindApplication { public class LoreMindApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -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.
* <p>
* Responsabilites :
* <ul>
* <li>Installer un nouveau JWT recu du relais (apres OAuth utilisateur)</li>
* <li>Calculer le {@link LicenseStatus} courant en respectant la grace period</li>
* <li>Renouveler le JWT avant expiration en appelant le relais</li>
* <li>Activer/desactiver le toggle "canal beta" cote utilisateur</li>
* <li>Distribuer les credentials registry pour le pull beta</li>
* </ul>
*/
@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<License> 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<License> 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<License> 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<RegistryCredentials> 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);
}
}
}

View File

@@ -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.
* <p>
* 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).
* <p>
* <b>Note securite :</b> {@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;
}

View File

@@ -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
) {}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.licensing;
/**
* Etat operationnel de la licence vis-a-vis de l'acces beta.
* <p>
* Calcule a partir de la presence de licence + son JWT exp + grace period.
* <ul>
* <li>{@link #NONE} : aucune licence installee</li>
* <li>{@link #VALID} : JWT non expire, acces beta autorise</li>
* <li>{@link #GRACE} : JWT expire mais dans la periode de tolerance ;
* acces beta toujours autorise, l'UI doit avertir</li>
* <li>{@link #EXPIRED} : au-dela de la grace period, acces beta refuse</li>
* <li>{@link #UNVERIFIABLE} : JWT impossible a verifier (cle publique manquante,
* signature invalide, claims malformes) — traite comme NONE pour la securite</li>
* </ul>
*/
public enum LicenseStatus {
NONE,
VALID,
GRACE,
EXPIRED,
UNVERIFIABLE
}

View File

@@ -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.
* <p>
* {@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
) {}

View File

@@ -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.
* <p>
* 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();
}

View File

@@ -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.
* <p>
* 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);
}
}
}

View File

@@ -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
}
}

View File

@@ -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.
* <p>
* Une seule licence par instance ({@code id = "current"} par convention).
*/
public interface LicenseRepository {
Optional<License> findCurrent();
License save(License license);
void deleteCurrent();
}

View File

@@ -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.
* <p>
* Format produit :
* <pre>{@code
* {
* "auths": {
* "ghcr.io": {
* "auth": "<base64(username:password)>"
* }
* }
* }
* }</pre>
*/
@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)
}
}
}

View File

@@ -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<String, String> body = Map.of("jwt", currentJwt);
ResponseEntity<JsonNode> 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<String, String> body = Map.of("jwt", currentJwt);
ResponseEntity<JsonNode> 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;
}
}

View File

@@ -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 :
* <ul>
* <li>renouvelle le JWT licence via le relais avant expiration (J-2)</li>
* <li>met a jour les credentials registry GHCR pour Watchtower
* (volume partage docker-config) tant que le canal beta est ON</li>
* <li>nettoie les credentials si la licence est invalidee ou le toggle OFF</li>
* </ul>
* 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 :
* <ul>
* <li>VALID/GRACE + beta ON -> ecrit/refresh les creds</li>
* <li>tout autre cas -> efface le fichier</li>
* </ul>
*/
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<RegistryCredentials> 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());
}
}
}

View File

@@ -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.
* <p>
* 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;
}
}
}

View File

@@ -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.
* <p>
* 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();
}
}

View File

@@ -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<LicenseJpaEntity, String> {
}

View File

@@ -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<License> 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();
}
}

View File

@@ -1,15 +1,20 @@
package com.loremind.infrastructure.updates; package com.loremind.infrastructure.updates;
import jakarta.annotation.PostConstruct; 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 org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.info.BuildProperties;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
@@ -19,172 +24,174 @@ import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.Optional;
/** /**
* Detection des mises a jour disponibles + declenchement via Watchtower. * Detection des mises a jour disponibles + declenchement via Watchtower.
* <p>
* <b>Strategie</b> : comparaison de versions semver, pas de digests.
* <ul>
* <li>La version courante de l'app est lue depuis {@link BuildProperties}
* (genere par spring-boot-maven-plugin dans META-INF/build-info.properties).</li>
* <li>Pour chaque image suivie, on interroge le registry sur
* {@code /v2/<image>/tags/list}, on extrait les tags semver, on prend le max.</li>
* <li>Si max > version courante => UPDATE_AVAILABLE.</li>
* <li>Si max == version courante => UP_TO_DATE.</li>
* <li>Si registry injoignable ou aucun tag valide => UNKNOWN.</li>
* </ul>
* *
* Strategie : * <b>Pourquoi pas les digests ?</b> Le bug historique etait : le baseline-digest
* - Au demarrage, on interroge le registry pour le digest courant de chaque * pose au @PostConstruct supposait que le pull venait d'avoir lieu (vrai apres
* image suivie ({@code update-check.images}). On stocke ces digests comme * `docker compose pull && up -d`, faux apres un simple restart de daemon ou un
* "baseline" (= ce que le conteneur en cours d'execution est cense faire * OOM). La version semver lue depuis le binaire est <b>fiable par construction</b> :
* tourner, puisque le `docker compose pull` precede toujours `up -d`). * c'est ce que le code source declare faire tourner.
* - Si l'init echoue (reseau Docker pas encore pret, registry transitoirement
* indisponible), un thread daemon de retry avec backoff complete les
* baselines manquantes en arriere-plan.
* - {@link #check()} re-interroge le registry et compare. Si un digest a
* change, une mise a jour est disponible. Si la baseline manque (echec
* de tous les retries), retourne {@link ImageStatusKind#UNKNOWN} pour
* cette image — JAMAIS d'alignement silencieux (eviterait des MAJ ratees).
* - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
*
* Apres un apply reussi, Watchtower redemarre core => ce service est
* re-instancie => baseline re-aligne sur le registry => check renvoie
* "pas de MAJ" (etat coherent).
*
* La feature est <b>desactivee silencieusement</b> si {@code WATCHTOWER_TOKEN}
* n'est pas defini : check/apply renvoient des reponses neutres et l'UI
* masque le badge / bouton.
*/ */
@Service @Service
public class UpdateCheckService { public class UpdateCheckService {
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class); private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
private static final List<MediaType> MANIFEST_ACCEPT = List.of(
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.v2+json"),
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.list.v2+json"),
MediaType.parseMediaType("application/vnd.oci.image.manifest.v1+json"),
MediaType.parseMediaType("application/vnd.oci.image.index.v1+json")
);
private final RestTemplate http; private final RestTemplate http;
private final String registry; private final String registry;
private final List<String> images; private final List<String> images;
private final String tag;
private final String watchtowerUrl; private final String watchtowerUrl;
private final String watchtowerToken; private final String watchtowerToken;
private final List<String> betaImages;
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>(); private final LicenseService licenseService;
/** Version semver courante du binaire (ex: "0.8.0"). Source de verite. */
private final String currentVersion;
public UpdateCheckService( public UpdateCheckService(
RestTemplateBuilder builder, RestTemplateBuilder builder,
@Value("${update-check.registry:}") String registry, @Value("${update-check.registry:}") String registry,
@Value("${update-check.images:}") String imagesCsv, @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-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,
LicenseService licenseService,
@Nullable BuildProperties buildProperties) {
this.http = builder this.http = builder
.setConnectTimeout(Duration.ofSeconds(5)) .setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15)) .setReadTimeout(Duration.ofSeconds(15))
.build(); .build();
this.registry = normalizeRegistry(registry); this.registry = normalizeRegistry(registry);
this.images = parseImages(imagesCsv); this.images = parseImages(imagesCsv);
this.tag = tag;
this.watchtowerUrl = watchtowerUrl; this.watchtowerUrl = watchtowerUrl;
this.watchtowerToken = watchtowerToken; this.watchtowerToken = watchtowerToken;
} this.betaImages = parseImages(betaImagesCsv);
this.licenseService = licenseService;
/** Backoff progressif (ms) pour retry de baseline en cas d'echec initial. */ this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null;
private static final long[] BASELINE_RETRY_BACKOFFS_MS = {2_000, 5_000, 15_000, 30_000, 60_000}; log.info("Update check init - registry={} images={} currentVersion={}",
this.registry, this.images, this.currentVersion);
@PostConstruct
void initBaseline() {
if (!isEnabled()) {
log.info("Update check disabled (WATCHTOWER_TOKEN not set)");
return;
}
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
boolean complete = tryBaselineMissing();
if (!complete) {
startBaselineRetryThread();
}
}
/**
* Tente de poser la baseline pour les images qui ne l'ont pas encore.
* @return true si TOUTES les images ont leur baseline apres cet essai.
*/
private boolean tryBaselineMissing() {
for (String image : images) {
if (baselineDigests.containsKey(image)) continue;
try {
String digest = fetchRemoteDigest(image);
if (digest != null) {
baselineDigests.put(image, digest);
log.debug("Baseline digest for {} = {}", image, digest);
}
} catch (Exception e) {
log.warn("Cannot baseline digest for {}: {}", image, e.getMessage());
}
}
return baselineDigests.size() == images.size();
}
/**
* Lance un thread daemon qui retente de poser les baselines manquantes
* avec backoff. Le thread s'arrete des que toutes les baselines sont
* posees, ou apres epuisement des backoffs (et alors {@link #check()}
* retournera UNKNOWN pour ces images jusqu'au prochain redemarrage).
*/
private void startBaselineRetryThread() {
Thread t = new Thread(() -> {
for (long backoff : BASELINE_RETRY_BACKOFFS_MS) {
try {
Thread.sleep(backoff);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
if (tryBaselineMissing()) {
log.info("Baseline complete after retry");
return;
}
}
log.warn("Baseline incomplete after all retries; check() will return UNKNOWN for missing images");
}, "update-baseline-retry");
t.setDaemon(true);
t.start();
} }
public boolean isEnabled() { public boolean isEnabled() {
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty(); return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
} }
/**
* @return version courante exposee aux endpoints (ex: pour affichage UI).
* {@code null} si build-info.properties absent (dev en IDE sans build Maven).
*/
public String getCurrentVersion() {
return currentVersion;
}
public UpdateStatus check() { public UpdateStatus check() {
if (!isEnabled()) { if (!isEnabled()) {
return new UpdateStatus(false, false, false, List.of(), Instant.now()); return new UpdateStatus(false, false, false, null, List.of(), Instant.now());
} }
if (currentVersion == null) {
log.warn("Update check : currentVersion absente (build-info manquant). Tous UNKNOWN.");
List<ImageStatus> statuses = new ArrayList<>();
for (String image : images) {
statuses.add(new ImageStatus(image, null, null, ImageStatusKind.UNKNOWN));
}
return new UpdateStatus(true, false, true, null, statuses, Instant.now());
}
List<ImageStatus> statuses = new ArrayList<>(); List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false; boolean anyUpdate = false;
boolean anyUnknown = false; boolean anyUnknown = false;
for (String image : images) { for (String image : images) {
String baseline = baselineDigests.get(image); String latest = null;
String remote = null;
try { try {
remote = fetchRemoteDigest(image); latest = fetchLatestSemverTag(registry, image, null);
} catch (Exception e) { } catch (Exception e) {
log.warn("Check failed for {}: {}", image, e.getMessage()); log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
} }
// PAS d'alignement lazy si baseline absente : ce serait un faux negatif
// silencieux. On reporte UNKNOWN pour que l'UI le signale.
ImageStatusKind kind; ImageStatusKind kind;
if (baseline == null || remote == null) { if (latest == null) {
kind = ImageStatusKind.UNKNOWN; kind = ImageStatusKind.UNKNOWN;
anyUnknown = true; anyUnknown = true;
} else if (baseline.equals(remote)) { } else {
int cmp = compareSemver(currentVersion, latest);
if (cmp >= 0) {
kind = ImageStatusKind.UP_TO_DATE;
} else {
kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true;
}
}
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
}
/**
* Verifie l'etat du canal beta (images privees GHCR) avec auth basique.
*/
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<RegistryCredentials> 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<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false;
boolean anyUnknown = false;
for (String image : betaImages) {
String latest = null;
try {
latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
} catch (Exception e) {
log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage());
}
ImageStatusKind kind;
if (latest == null) {
kind = ImageStatusKind.UNKNOWN;
anyUnknown = true;
} else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
kind = ImageStatusKind.UP_TO_DATE; kind = ImageStatusKind.UP_TO_DATE;
} else { } else {
kind = ImageStatusKind.UPDATE_AVAILABLE; kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true; anyUpdate = true;
} }
statuses.add(new ImageStatus(image, baseline, remote, kind)); statuses.add(new ImageStatus(image, currentVersion, latest, kind));
} }
return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now()); return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
} }
public void apply() { public void apply() {
@@ -193,10 +200,6 @@ public class UpdateCheckService {
} }
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(watchtowerToken); headers.setBearerAuth(watchtowerToken);
// Watchtower /v1/update declenche un scan+update immediat de tous les
// conteneurs labellises. La reponse est synchrone et peut prendre
// plusieurs secondes; en cas de redemarrage de core, le client
// recevra une connexion coupee — c'est attendu, l'UI le gere.
http.exchange( http.exchange(
watchtowerUrl + "/v1/update", watchtowerUrl + "/v1/update",
HttpMethod.POST, HttpMethod.POST,
@@ -205,40 +208,121 @@ public class UpdateCheckService {
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Registry HTTP API v2 // Registry HTTP API v2 - tags listing + auth bearer
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
private String fetchRemoteDigest(String image) { /**
String url = registry + "/v2/" + image + "/manifests/" + tag; * Interroge le registry pour la liste des tags d'une image, parse les
* versions semver et retourne la plus elevee. {@code null} si echec
* ou aucun tag valide.
*
* @param registryUrl URL normalisee (ex: "https://ghcr.io")
* @param image nom de l'image (ex: "igmlcreation/loremind-core")
* @param authHeader optionnel - "Basic ..." pour les registries prives
*/
private String fetchLatestSemverTag(String registryUrl, String image, @Nullable String authHeader) {
String url = registryUrl + "/v2/" + image + "/tags/list";
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setAccept(MANIFEST_ACCEPT); headers.setAccept(List.of(MediaType.APPLICATION_JSON));
if (authHeader != null) {
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
}
TagsListResponse body;
try { try {
return digestCall(url, headers); body = tagsCall(url, headers);
} catch (HttpClientErrorException.Unauthorized e) { } catch (HttpClientErrorException.Unauthorized e) {
String www = e.getResponseHeaders() == null ? null String www = e.getResponseHeaders() == null ? null
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE); : e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
String token = obtainBearerToken(www); String token = obtainBearerToken(www, authHeader);
if (token == null) { if (token == null) {
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www); log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
return null; return null;
} }
headers.setBearerAuth(token); HttpHeaders bearerHeaders = new HttpHeaders();
return digestCall(url, headers); bearerHeaders.setAccept(List.of(MediaType.APPLICATION_JSON));
bearerHeaders.setBearerAuth(token);
body = tagsCall(url, bearerHeaders);
} }
if (body == null || body.tags == null || body.tags.isEmpty()) return null;
return findMaxSemver(body.tags);
} }
private String digestCall(String url, HttpHeaders headers) { private TagsListResponse tagsCall(String url, HttpHeaders headers) {
ResponseEntity<Void> resp = http.exchange( ResponseEntity<TagsListResponse> resp = http.exchange(
url, HttpMethod.HEAD, new HttpEntity<>(headers), Void.class); url, HttpMethod.GET, new HttpEntity<>(headers), TagsListResponse.class);
return resp.getHeaders().getFirst("Docker-Content-Digest"); return resp.getBody();
} }
/** /**
* Suit le challenge {@code WWW-Authenticate: Bearer realm="...",service="...",scope="..."} * Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
* pour obtenir un jeton (anonyme — suffisant pour les images publiques). * (1 a 3 chiffres separes par des points, optionnel prefix "v"), retourne le max.
* Pre-release / build metadata sont strippes pour la comparaison.
*/
@Nullable
static String findMaxSemver(List<String> tags) {
String maxTag = null;
int[] maxParts = null;
for (String t : tags) {
if (t == null || t.isBlank()) continue;
int[] parts = parseSemver(t);
if (parts == null) continue;
if (maxParts == null || compareParts(parts, maxParts) > 0) {
maxParts = parts;
maxTag = t;
}
}
return maxTag;
}
/** @return [major, minor, patch] ou null si non parsable. */
@Nullable
static int[] parseSemver(String tag) {
if (tag == null) return null;
String s = tag.trim();
if (s.isEmpty()) return null;
if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
int dashIdx = s.indexOf('-');
if (dashIdx > 0) s = s.substring(0, dashIdx);
int plusIdx = s.indexOf('+');
if (plusIdx > 0) s = s.substring(0, plusIdx);
String[] parts = s.split("\\.");
if (parts.length < 1 || parts.length > 3) return null;
int[] result = new int[]{0, 0, 0};
for (int i = 0; i < parts.length; i++) {
try {
int v = Integer.parseInt(parts[i]);
if (v < 0) return null;
result[i] = v;
} catch (NumberFormatException e) {
return null;
}
}
return result;
}
/** Compare deux versions semver brutes (sans prefix). Negatif si a < b. */
static int compareSemver(String a, String b) {
int[] aParts = parseSemver(a);
int[] bParts = parseSemver(b);
if (aParts == null || bParts == null) return 0;
return compareParts(aParts, bParts);
}
private static int compareParts(int[] a, int[] b) {
for (int i = 0; i < 3; i++) {
int diff = Integer.compare(a[i], b[i]);
if (diff != 0) return diff;
}
return 0;
}
/**
* Suit le challenge {@code WWW-Authenticate: Bearer realm="..."} pour obtenir
* un token. Si {@code basicAuth} est fourni, l'utilise pour l'echange (cas
* registry prive). Sinon anonyme (cas registry public).
*/ */
@SuppressWarnings("rawtypes") @SuppressWarnings("rawtypes")
private String obtainBearerToken(String wwwAuth) { private String obtainBearerToken(@Nullable String wwwAuth, @Nullable String basicAuth) {
if (wwwAuth == null) return null; if (wwwAuth == null) return null;
String prefix = "Bearer "; String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null; if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
@@ -250,23 +334,20 @@ public class UpdateCheckService {
for (String key : new String[]{"service", "scope"}) { for (String key : new String[]{"service", "scope"}) {
String v = params.get(key); String v = params.get(key);
if (v != null) { if (v != null) {
// URLEncoder fait du "form encoding" qui transforme `:` et `/`
// en %3A et %2F. La plupart des registries (Docker Hub, Gitea)
// acceptent les deux, mais GHCR est strict et rejette le scope
// encode (403 DENIED). On preserve donc `:` et `/` dans la
// valeur, conformement a ce que GHCR attend
// (et que docker pull lui-meme envoie).
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8) String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
.replace("%3A", ":") .replace("%3A", ":")
.replace("%2F", "/"); .replace("%2F", "/");
url.append(hasQuery ? '&' : '?') url.append(hasQuery ? '&' : '?').append(key).append('=').append(encoded);
.append(key).append('=')
.append(encoded);
hasQuery = true; hasQuery = true;
} }
} }
try { try {
ResponseEntity<Map> resp = http.getForEntity(url.toString(), Map.class); HttpHeaders headers = new HttpHeaders();
if (basicAuth != null) {
headers.set(HttpHeaders.AUTHORIZATION, basicAuth);
}
ResponseEntity<Map> resp = http.exchange(url.toString(), HttpMethod.GET,
new HttpEntity<>(headers), Map.class);
Map<?, ?> body = resp.getBody(); Map<?, ?> body = resp.getBody();
if (body == null) return null; if (body == null) return null;
Object t = body.get("token"); Object t = body.get("token");
@@ -327,41 +408,52 @@ public class UpdateCheckService {
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Records de retour (sortis sous forme JSON par Jackson) // Records / DTO
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/**
* Etat tri-state d'une image vis-a-vis du registry.
* <ul>
* <li>{@link #UP_TO_DATE} : digest local == digest remote.</li>
* <li>{@link #UPDATE_AVAILABLE} : digests differents, MAJ disponible.</li>
* <li>{@link #UNKNOWN} : impossible de comparer (baseline ou remote manquant).
* L'UI doit afficher un avertissement plutot que de declarer "a jour".</li>
* </ul>
*/
public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN } public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN }
public record UpdateStatus( public record UpdateStatus(
boolean enabled, boolean enabled,
boolean updateAvailable, boolean updateAvailable,
boolean anyUnknown, boolean anyUnknown,
String currentVersion,
List<ImageStatus> images, List<ImageStatus> images,
Instant checkedAt) {} Instant checkedAt) {}
/** /**
* Le champ {@code updateAvailable} est conserve pour la compatibilite * Statut par image. {@code localVersion} = version embarquee dans le binaire ;
* avec les anciens clients ; il est strictement derive de {@code status} * {@code remoteVersion} = plus haute version semver trouvee dans le registry.
* dans le constructeur compact. * {@code updateAvailable} est derive de {@code status} (back-compat front).
*/ */
public record ImageStatus( public record ImageStatus(
String image, String image,
String localDigest, String localVersion,
String remoteDigest, String remoteVersion,
ImageStatusKind status, ImageStatusKind status,
boolean updateAvailable) { boolean updateAvailable) {
public ImageStatus(String image, String localDigest, String remoteDigest, ImageStatusKind status) { public ImageStatus(String image, String localVersion, String remoteVersion, ImageStatusKind status) {
this(image, localDigest, remoteDigest, status, status == ImageStatusKind.UPDATE_AVAILABLE); this(image, localVersion, remoteVersion, status, status == ImageStatusKind.UPDATE_AVAILABLE);
} }
} }
public record BetaStatus(
boolean enabled,
boolean updateAvailable,
boolean anyUnknown,
List<ImageStatus> images,
Instant checkedAt,
String disabledReason) {
public static BetaStatus disabled(String reason) {
return new BetaStatus(false, false, false, List.of(), Instant.now(), reason);
}
}
/** DTO pour deserialisation Jackson de /v2/.../tags/list. */
static class TagsListResponse {
public String name;
public List<String> tags;
}
} }

View File

@@ -67,6 +67,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/settings/**").hasRole("ADMIN") .requestMatchers("/api/settings/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/license/**").hasRole("ADMIN")
.anyRequest().permitAll() .anyRequest().permitAll()
) )
.httpBasic(basic -> {}); .httpBasic(basic -> {});

View File

@@ -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.
*
* <ul>
* <li>{@code GET /api/license} : etat courant (status, tier, expiration...)</li>
* <li>{@code GET /api/license/connect-url} : URL OAuth a ouvrir dans le navigateur</li>
* <li>{@code POST /api/license/install} : colle un JWT recu du relais</li>
* <li>{@code DELETE /api/license} : deconnecte Patreon (efface la licence)</li>
* <li>{@code POST /api/license/refresh} : force un refresh manuel</li>
* <li>{@code PUT /api/license/beta-channel} : active/desactive le canal beta</li>
* </ul>
*/
@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<String, String> 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<Void> disconnect() {
licenseService.disconnect();
return ResponseEntity.noContent().build();
}
@PostMapping("/refresh")
public ResponseEntity<LicenseStatusDTO> 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) {}
}

View File

@@ -1,6 +1,7 @@
package com.loremind.infrastructure.web.controller; package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.updates.UpdateCheckService; import com.loremind.infrastructure.updates.UpdateCheckService;
import com.loremind.infrastructure.updates.UpdateCheckService.BetaStatus;
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus; import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -44,6 +45,12 @@ public class UpdatesController {
return updates.check(); return updates.check();
} }
@GetMapping("/check-beta")
public BetaStatus checkBeta() {
guardDemoMode();
return updates.checkBeta();
}
@PostMapping("/apply") @PostMapping("/apply")
public ResponseEntity<Map<String, Object>> apply() { public ResponseEntity<Map<String, Object>> apply() {
guardDemoMode(); guardDemoMode();

View File

@@ -0,0 +1,35 @@
package com.loremind.infrastructure.web.controller;
import org.springframework.boot.info.BuildProperties;
import org.springframework.lang.Nullable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Endpoint public exposant la version courante du binaire.
* <p>
* Consomme par le frontend pour detecter qu'une mise a jour a ete deployee
* pendant qu'un onglet utilisateur etait deja ouvert : si la version polled
* differe de celle observee au boot, l'UI affiche un bandeau "rechargez".
* <p>
* Volontairement public (pas d'auth) : la version est deja exposee dans le
* JAR / l'image Docker, aucun risque de leak.
*/
@RestController
@RequestMapping("/api/version")
public class VersionController {
private final String version;
public VersionController(@Nullable BuildProperties buildProperties) {
this.version = buildProperties != null ? buildProperties.getVersion() : "dev";
}
@GetMapping
public Map<String, String> getVersion() {
return Map.of("version", version);
}
}

View File

@@ -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()
);
}
}

View File

@@ -65,6 +65,39 @@ app.demo-mode=${DEMO_MODE:false}
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide. # Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
update-check.registry=${UPDATE_CHECK_REGISTRY:} update-check.registry=${UPDATE_CHECK_REGISTRY:}
update-check.images=${UPDATE_CHECK_IMAGES:} 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-url=${WATCHTOWER_URL:http://watchtower:8080}
update-check.watchtower-token=${WATCHTOWER_TOKEN:} 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}
# 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}

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEArbfaqBq54HJR1pKqliTShKrNIab32gpBwSTDw90I4wg=
-----END PUBLIC KEY-----

View File

@@ -1,17 +1,21 @@
package com.loremind.infrastructure.updates; package com.loremind.infrastructure.updates;
import com.loremind.application.licensing.LicenseService;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus; import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind; import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus; import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.info.BuildProperties;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import java.util.Map; import java.util.List;
import java.util.concurrent.ConcurrentHashMap; import java.util.Properties;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -19,64 +23,68 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** /**
* Test unitaire pour UpdateCheckService. * Tests UpdateCheckService - approche semver (post-refactor v0.8.x).
* *
* Couvre les invariants critiques de la detection de MAJ : * Couvre :
* - feature desactivee si token absent * - feature desactivee si WATCHTOWER_TOKEN absent
* - status UP_TO_DATE quand baseline == remote * - UP_TO_DATE quand version locale == max(tags remote)
* - status UPDATE_AVAILABLE quand baseline != remote * - UPDATE_AVAILABLE quand un tag plus eleve existe
* - status UNKNOWN quand baseline manque (PAS d'alignement lazy — invariant * - UNKNOWN quand le registry echoue
* central, regression historique) * - UNKNOWN quand BuildProperties est absent (currentVersion = null)
* - status UNKNOWN quand remote impossible a fetcher * - parseSemver / findMaxSemver / compareSemver utilitaires
* - drapeaux top-level updateAvailable / anyUnknown coherents
* - back-compat : champ updateAvailable sur ImageStatus = (status == UPDATE_AVAILABLE)
*/ */
public class UpdateCheckServiceTest { public class UpdateCheckServiceTest {
private static UpdateCheckService newService(String token) { private static UpdateCheckService newService(String token, String currentVersion) {
BuildProperties bp = null;
if (currentVersion != null) {
Properties p = new Properties();
p.setProperty("version", currentVersion);
bp = new BuildProperties(p);
}
// licenseService null : la beta est testee separement, ces tests
// couvrent uniquement le canal stable.
return new UpdateCheckService( return new UpdateCheckService(
new RestTemplateBuilder(), new RestTemplateBuilder(),
"ghcr.io", "ghcr.io",
"igmlcreation/loremind-core,igmlcreation/loremind-brain", "igmlcreation/loremind-core,igmlcreation/loremind-brain",
"latest",
"http://watchtower:8080", "http://watchtower:8080",
token token,
"",
null,
bp
); );
} }
/**
* Injecte un RestTemplate moque dans le service deja construit, et pose
* directement les baselines pour eviter les vrais appels HTTP.
*/
@SuppressWarnings("unchecked")
private static void setBaselines(UpdateCheckService svc, Map<String, String> baselines) {
((Map<String, String>) ReflectionTestUtils.getField(svc, "baselineDigests")).putAll(baselines);
}
private static RestTemplate stubHttp(UpdateCheckService svc) { private static RestTemplate stubHttp(UpdateCheckService svc) {
RestTemplate http = mock(RestTemplate.class); RestTemplate http = mock(RestTemplate.class);
ReflectionTestUtils.setField(svc, "http", http); ReflectionTestUtils.setField(svc, "http", http);
return http; return http;
} }
private static void stubRemoteDigest(RestTemplate http, String image, String digest) { private static void stubTags(RestTemplate http, String image, List<String> tags) {
HttpHeaders headers = new HttpHeaders(); TagsListResponse body = new TagsListResponse();
if (digest != null) headers.add("Docker-Content-Digest", digest); body.name = image;
ResponseEntity<Void> resp = new ResponseEntity<>(headers, org.springframework.http.HttpStatus.OK); body.tags = tags;
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"), ResponseEntity<TagsListResponse> resp = new ResponseEntity<>(body, HttpStatus.OK);
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class))) when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
.thenReturn(resp); .thenReturn(resp);
} }
private static void stubRemoteFailure(RestTemplate http, String image) { private static void stubTagsFailure(RestTemplate http, String image) {
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"), when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class))) eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
.thenThrow(new RuntimeException("network down")); .thenThrow(new RuntimeException("network down"));
} }
// -----------------------------------------------------------------
// Comportement du service
// -----------------------------------------------------------------
@Test @Test
void disabledWhenTokenMissing() { void disabledWhenTokenMissing() {
UpdateCheckService svc = newService(""); UpdateCheckService svc = newService("", "0.8.0");
UpdateStatus status = svc.check(); UpdateStatus status = svc.check();
assertFalse(status.enabled()); assertFalse(status.enabled());
assertFalse(status.updateAvailable()); assertFalse(status.updateAvailable());
@@ -85,118 +93,153 @@ public class UpdateCheckServiceTest {
} }
@Test @Test
void upToDate_whenBaselineEqualsRemote() { void upToDate_whenCurrentEqualsMaxRemote() {
UpdateCheckService svc = newService("token"); UpdateCheckService svc = newService("token", "0.8.0");
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
setBaselines(svc, Map.of(
"igmlcreation/loremind-core", "sha256:aaa",
"igmlcreation/loremind-brain", "sha256:bbb"
));
RestTemplate http = stubHttp(svc); RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:aaa"); stubTags(http, "igmlcreation/loremind-core",
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb"); List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"));
stubTags(http, "igmlcreation/loremind-brain",
List.of("0.7.0", "0.8.0", "latest"));
UpdateStatus status = svc.check(); UpdateStatus status = svc.check();
assertTrue(status.enabled()); assertTrue(status.enabled());
assertFalse(status.updateAvailable()); assertFalse(status.updateAvailable());
assertFalse(status.anyUnknown()); assertFalse(status.anyUnknown());
assertEquals("0.8.0", status.currentVersion());
for (ImageStatus img : status.images()) { for (ImageStatus img : status.images()) {
assertEquals(ImageStatusKind.UP_TO_DATE, img.status()); assertEquals(ImageStatusKind.UP_TO_DATE, img.status());
assertEquals("0.8.0", img.localVersion());
assertEquals("0.8.0", img.remoteVersion());
assertFalse(img.updateAvailable(), "back-compat bool"); assertFalse(img.updateAvailable(), "back-compat bool");
} }
} }
@Test @Test
void updateAvailable_whenRemoteDiffers() { void updateAvailable_whenRemoteHigher() {
UpdateCheckService svc = newService("token"); UpdateCheckService svc = newService("token", "0.7.2");
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
setBaselines(svc, Map.of(
"igmlcreation/loremind-core", "sha256:OLD",
"igmlcreation/loremind-brain", "sha256:bbb"
));
RestTemplate http = stubHttp(svc); RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW"); stubTags(http, "igmlcreation/loremind-core",
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb"); List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"));
stubTags(http, "igmlcreation/loremind-brain",
List.of("0.7.2", "latest"));
UpdateStatus status = svc.check(); UpdateStatus status = svc.check();
assertTrue(status.updateAvailable()); assertTrue(status.updateAvailable());
assertFalse(status.anyUnknown()); assertFalse(status.anyUnknown());
ImageStatus core = status.images().stream() ImageStatus core = status.images().stream()
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow(); .filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status()); assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status());
assertEquals("0.7.2", core.localVersion());
assertEquals("0.8.0", core.remoteVersion());
assertTrue(core.updateAvailable(), "back-compat bool"); assertTrue(core.updateAvailable(), "back-compat bool");
ImageStatus brain = status.images().stream() ImageStatus brain = status.images().stream()
.filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow(); .filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UP_TO_DATE, brain.status()); assertEquals(ImageStatusKind.UP_TO_DATE, brain.status());
} }
@Test @Test
void unknown_whenBaselineMissing_DOES_NOT_lazyAlign() { void unknown_whenRegistryFails() {
// INVARIANT CENTRAL : si la baseline est absente (echec init au boot), UpdateCheckService svc = newService("token", "0.8.0");
// on NE DOIT PAS aligner lazy sur le remote courant — sinon une MAJ
// pousse APRES le boot serait declaree "a jour" silencieusement.
UpdateCheckService svc = newService("token");
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
// baseline DELIBEREMENT vide
RestTemplate http = stubHttp(svc); RestTemplate http = stubHttp(svc);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:remote-now"); stubTagsFailure(http, "igmlcreation/loremind-core");
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:remote-now-2"); stubTags(http, "igmlcreation/loremind-brain", List.of("0.8.0"));
UpdateStatus status = svc.check();
assertTrue(status.anyUnknown());
ImageStatus core = status.images().stream()
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UNKNOWN, core.status());
assertNull(core.remoteVersion());
assertEquals("0.8.0", core.localVersion());
}
@Test
void unknown_whenNoValidSemverTags() {
UpdateCheckService svc = newService("token", "0.8.0");
RestTemplate http = stubHttp(svc);
stubTags(http, "igmlcreation/loremind-core", List.of("latest", "stable", "main"));
stubTags(http, "igmlcreation/loremind-brain", List.of("0.8.0"));
UpdateStatus status = svc.check();
assertTrue(status.anyUnknown());
ImageStatus core = status.images().stream()
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UNKNOWN, core.status());
assertNull(core.remoteVersion());
}
@Test
void unknown_whenBuildPropertiesAbsent() {
// INVARIANT : pas de version courante => tout UNKNOWN, jamais "a jour"
// par defaut. Evite de declarer "a jour" un build dev sans build-info.
UpdateCheckService svc = newService("token", null);
RestTemplate http = stubHttp(svc);
// Meme si on stub des tags, le service doit bypass et renvoyer UNKNOWN
stubTags(http, "igmlcreation/loremind-core", List.of("0.8.0"));
UpdateStatus status = svc.check(); UpdateStatus status = svc.check();
assertTrue(status.enabled()); assertTrue(status.enabled());
assertFalse(status.updateAvailable()); assertFalse(status.updateAvailable());
assertTrue(status.anyUnknown()); assertTrue(status.anyUnknown());
assertNull(status.currentVersion());
for (ImageStatus img : status.images()) { for (ImageStatus img : status.images()) {
assertEquals(ImageStatusKind.UNKNOWN, img.status()); assertEquals(ImageStatusKind.UNKNOWN, img.status());
assertNull(img.localDigest());
assertNotNull(img.remoteDigest()); // remote OK, baseline manquante
} }
}
// VERIFICATION CRITIQUE : la baseline ne doit PAS avoir ete posee. // -----------------------------------------------------------------
@SuppressWarnings("unchecked") // Utilitaires semver
Map<String, String> baselines = (Map<String, String>) ReflectionTestUtils.getField(svc, "baselineDigests"); // -----------------------------------------------------------------
assertTrue(baselines.isEmpty(),
"check() ne doit JAMAIS aligner lazy la baseline sur le remote — " @Test
+ "regression de bug historique (faux negatif silencieux)."); void parseSemver_acceptsCommonFormats() {
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0"));
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("v0.8.0"));
assertArrayEquals(new int[]{1, 0, 0}, UpdateCheckService.parseSemver("1.0.0"));
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0-beta.1"));
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0+build.42"));
} }
@Test @Test
void unknown_whenRemoteFetchFails() { void parseSemver_rejectsInvalid() {
UpdateCheckService svc = newService("token"); assertNull(UpdateCheckService.parseSemver(null));
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>()); assertNull(UpdateCheckService.parseSemver(""));
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:aaa", assertNull(UpdateCheckService.parseSemver("latest"));
"igmlcreation/loremind-brain", "sha256:bbb")); assertNull(UpdateCheckService.parseSemver("stable"));
RestTemplate http = stubHttp(svc); assertNull(UpdateCheckService.parseSemver("0.8.0.1.2"));
stubRemoteFailure(http, "igmlcreation/loremind-core"); assertNull(UpdateCheckService.parseSemver("0.x.0"));
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
UpdateStatus status = svc.check();
assertFalse(status.updateAvailable());
assertTrue(status.anyUnknown());
ImageStatus core = status.images().stream()
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
assertEquals(ImageStatusKind.UNKNOWN, core.status());
assertNull(core.remoteDigest());
assertEquals("sha256:aaa", core.localDigest()); // baseline preservee
} }
@Test @Test
void mixedStatuses_anyUnknownAndAnyUpdateBothTrue() { void compareSemver_basic() {
UpdateCheckService svc = newService("token"); assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0);
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>()); assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0);
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:OLD")); assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0"));
// brain n'a pas de baseline -> UNKNOWN assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0"));
RestTemplate http = stubHttp(svc); assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0);
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW"); assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0);
stubRemoteFailure(http, "igmlcreation/loremind-brain"); }
UpdateStatus status = svc.check(); @Test
void findMaxSemver_picksHighest() {
assertEquals("0.8.0", UpdateCheckService.findMaxSemver(
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest")));
assertEquals("0.10.0", UpdateCheckService.findMaxSemver(
List.of("0.8.0", "0.10.0", "0.9.5")));
assertEquals("v1.0.0", UpdateCheckService.findMaxSemver(
List.of("v0.8.0", "v1.0.0", "latest")));
}
assertTrue(status.updateAvailable(), "core a une MAJ disponible"); @Test
assertTrue(status.anyUnknown(), "brain est UNKNOWN"); void findMaxSemver_returnsNullWhenNoValidTag() {
assertNull(UpdateCheckService.findMaxSemver(List.of("latest", "stable", "main")));
assertNull(UpdateCheckService.findMaxSemver(List.of()));
} }
} }

View File

@@ -94,6 +94,19 @@ services:
UPDATE_CHECK_TAG: ${TAG:-latest} UPDATE_CHECK_TAG: ${TAG:-latest}
WATCHTOWER_URL: http://watchtower:8080 WATCHTOWER_URL: http://watchtower:8080
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-} 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 restart: unless-stopped
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe). # Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
@@ -169,7 +182,14 @@ services:
profiles: ["autoupdate"] profiles: ["autoupdate"]
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /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: 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_LABEL_ENABLE: "true"
WATCHTOWER_CLEANUP: "true" WATCHTOWER_CLEANUP: "true"
WATCHTOWER_INCLUDE_RESTARTING: "true" WATCHTOWER_INCLUDE_RESTARTING: "true"
@@ -191,3 +211,6 @@ volumes:
minio-data: minio-data:
brain-data: brain-data:
ollama-data: ollama-data:
# Volume partage Core <-> Watchtower : config.json Docker pour
# l'authentification au registry prive GHCR (canal beta Patreon).
docker-config:

View File

@@ -40,7 +40,7 @@
Auteur : ietm64 Auteur : ietm64
Licence : AGPL-3.0 Licence : AGPL-3.0
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
Version : 0.7.1 Version : 0.8.1
.LINK .LINK
https://github.com/IGMLcreation/LoreMind https://github.com/IGMLcreation/LoreMind

View File

@@ -1,7 +1,8 @@
FROM node:20-alpine AS build FROM node:20-bookworm-slim AS build
WORKDIR /build WORKDIR /build
RUN npm install -g npm@latest
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci --include=dev --ignore-scripts --no-audit --no-fund --no-progress
COPY . . COPY . .
# Neutralise les URLs absolues hardcodees dans les services (dette assumee : # Neutralise les URLs absolues hardcodees dans les services (dette assumee :

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.7.1", "version": "0.8.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.7.0", "version": "0.8.1",
"dependencies": { "dependencies": {
"@angular/animations": "^17.0.0", "@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0", "@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.7.1", "version": "0.8.1",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -1,3 +1,5 @@
<app-update-banner></app-update-banner>
<div class="app-container"> <div class="app-container">
<app-sidebar></app-sidebar> <app-sidebar></app-sidebar>

View File

@@ -4,13 +4,23 @@ import { RouterOutlet } from '@angular/router';
import { SidebarComponent } from './sidebar/sidebar.component'; import { SidebarComponent } from './sidebar/sidebar.component';
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component'; import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
import { GlobalSearchComponent } from './shared/global-search/global-search.component'; import { GlobalSearchComponent } from './shared/global-search/global-search.component';
import { UpdateBannerComponent } from './shared/update-banner/update-banner.component';
import { LayoutService } from './services/layout.service'; import { LayoutService } from './services/layout.service';
import { GlobalSearchService } from './services/global-search.service'; import { GlobalSearchService } from './services/global-search.service';
import { VersionCheckerService } from './services/version-checker.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [RouterOutlet, SidebarComponent, SecondarySidebarComponent, GlobalSearchComponent, AsyncPipe, NgIf], imports: [
RouterOutlet,
SidebarComponent,
SecondarySidebarComponent,
GlobalSearchComponent,
UpdateBannerComponent,
AsyncPipe,
NgIf,
],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'] styleUrls: ['./app.component.scss']
}) })
@@ -19,8 +29,14 @@ export class AppComponent {
constructor( constructor(
private layoutService: LayoutService, private layoutService: LayoutService,
private globalSearch: GlobalSearchService private globalSearch: GlobalSearchService,
) {} versionChecker: VersionCheckerService,
) {
// Demarre la detection de mise a jour en arriere-plan.
// Si une nouvelle version est deployee pendant la session, l'UpdateBanner
// s'affichera automatiquement.
versionChecker.start();
}
@HostListener('document:keydown', ['$event']) @HostListener('document:keydown', ['$event'])
onKeydown(event: KeyboardEvent): void { onKeydown(event: KeyboardEvent): void {

View File

@@ -89,7 +89,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
order: this.existingArcCount + 1, order: this.existingArcCount + 1,
icon: this.selectedIcon icon: this.selectedIcon
}).subscribe({ }).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]), next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id, 'edit']),
error: () => console.error('Erreur lors de la création de l\'arc') error: () => console.error('Erreur lors de la création de l\'arc')
}); });
} }

View File

@@ -94,7 +94,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
order: this.existingSceneCount + 1, order: this.existingSceneCount + 1,
icon: this.selectedIcon icon: this.selectedIcon
}).subscribe({ }).subscribe({
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]), next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id, 'edit']),
error: () => console.error('Erreur lors de la création de la scène') error: () => console.error('Erreur lors de la création de la scène')
}); });
} }

View File

@@ -254,7 +254,7 @@ export class PageCreateComponent implements OnInit, OnDestroy {
next: (created) => { next: (created) => {
const updated = { ...created, values }; const updated = { ...created, values };
this.pageService.update(created.id!, updated).subscribe({ this.pageService.update(created.id!, updated).subscribe({
next: () => this.router.navigate(['/lore', this.loreId, 'pages', created.id]), next: () => this.router.navigate(['/lore', this.loreId, 'pages', created.id, 'edit']),
error: () => this.wizardError = 'Page créée, mais impossible d\'appliquer les valeurs.' error: () => this.wizardError = 'Page créée, mais impossible d\'appliquer les valeurs.'
}); });
}, },

View File

@@ -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;
localVersion: string | null;
remoteVersion: 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<LicenseStatusDTO | null> {
return this.http.get<LicenseStatusDTO>(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<LicenseStatusDTO | { error: string }> {
return this.http.post<LicenseStatusDTO>(`${this.apiUrl}/install`, { jwt }, this.authOptions).pipe(
catchError((err) => of({ error: err?.error?.error ?? 'Echec de l\'installation' }))
);
}
disconnect(): Observable<boolean> {
return this.http.delete<void>(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<boolean>;
}
refresh(): Observable<LicenseStatusDTO | null> {
return this.http.post<LicenseStatusDTO>(`${this.apiUrl}/refresh`, null, this.authOptions).pipe(
catchError(() => of(null))
);
}
setBetaChannel(enabled: boolean): Observable<LicenseStatusDTO | null> {
return this.http.put<LicenseStatusDTO>(`${this.apiUrl}/beta-channel`, { enabled }, this.authOptions).pipe(
catchError(() => of(null))
);
}
checkBeta(): Observable<BetaStatusDTO | null> {
return this.http.get<BetaStatusDTO>('/api/admin/updates/check-beta', this.authOptions).pipe(
catchError(() => of(null))
);
}
}

View File

@@ -3,19 +3,18 @@ import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, catchError, of, tap } from 'rxjs'; import { BehaviorSubject, Observable, catchError, of, tap } from 'rxjs';
/** /**
* Reflet de UpdateCheckService.UpdateStatus cote backend. * Reflet de UpdateCheckService.UpdateStatus cote backend (post-refactor v0.8.x).
* *
* Etat tri-state par image : UP_TO_DATE / UPDATE_AVAILABLE / UNKNOWN. * Etat tri-state par image : UP_TO_DATE / UPDATE_AVAILABLE / UNKNOWN.
* UNKNOWN signale que la comparaison est impossible (baseline absente ou * Comparaison faite par version semver (BuildProperties cote backend), plus
* remote injoignable) — l'UI doit afficher un avertissement plutot que * fiable que les digests qui ne survivaient pas a un restart sans pull.
* d'annoncer "a jour" silencieusement.
*/ */
export type ImageStatusKind = 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN'; export type ImageStatusKind = 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
export interface ImageStatus { export interface ImageStatus {
image: string; image: string;
localDigest: string | null; localVersion: string | null;
remoteDigest: string | null; remoteVersion: string | null;
status: ImageStatusKind; status: ImageStatusKind;
/** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */ /** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */
updateAvailable: boolean; updateAvailable: boolean;
@@ -27,6 +26,8 @@ export interface UpdateStatus {
updateAvailable: boolean; updateAvailable: boolean;
/** True si au moins une image a status === 'UNKNOWN'. */ /** True si au moins une image a status === 'UNKNOWN'. */
anyUnknown: boolean; anyUnknown: boolean;
/** Version courante du binaire (BuildProperties). null si build-info absent. */
currentVersion: string | null;
images: ImageStatus[]; images: ImageStatus[];
checkedAt: string; checkedAt: string;
} }

View File

@@ -0,0 +1,94 @@
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
/**
* Detecte qu'une nouvelle version de LoreMind a ete deployee pendant qu'un
* onglet utilisateur est deja ouvert.
*
* Strategie : polling toutes les {@link POLL_INTERVAL_MS} sur /api/version.
* La version observee au boot est figee comme reference. Si la version polled
* differe, {@link hasUpdate} passe a true et l'UI peut afficher un bandeau
* proposant un reload.
*
* Pourquoi pas un Service Worker ? Trop lourd pour le besoin (offline pas
* pertinent, lifecycle complexe). Polling simple = ~15 lignes, fait le job.
*/
@Injectable({ providedIn: 'root' })
export class VersionCheckerService {
private static readonly POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
private static readonly INITIAL_DELAY_MS = 30 * 1000; // 30s avant premier poll
private readonly _bootVersion = signal<string | null>(null);
private readonly _remoteVersion = signal<string | null>(null);
private readonly _hasUpdate = computed(
() => {
const boot = this._bootVersion();
const remote = this._remoteVersion();
return boot !== null && remote !== null && boot !== remote;
}
);
/** True quand la version remote differe de celle observee au boot. */
readonly hasUpdate = this._hasUpdate;
/** Version observee a l'ouverture du tab (figee). */
readonly bootVersion = this._bootVersion.asReadonly();
/** Derniere version observee sur le backend. */
readonly remoteVersion = this._remoteVersion.asReadonly();
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(private http: HttpClient) {}
/**
* Demarre le polling. A appeler une fois au boot de l'app.
* Ignore si deja demarre (idempotent).
*/
start(): void {
if (this.timer !== null) return;
void this.fetchInitial();
this.timer = setInterval(
() => void this.poll(),
VersionCheckerService.POLL_INTERVAL_MS,
);
}
stop(): void {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
}
/** Recharge l'app (force re-fetch index.html + assets). */
reload(): void {
window.location.reload();
}
private async fetchInitial(): Promise<void> {
// Petit delai pour laisser le bootstrap se finir avant le premier appel.
await new Promise(r => setTimeout(r, VersionCheckerService.INITIAL_DELAY_MS));
const v = await this.fetchVersion();
if (v && this._bootVersion() === null) {
this._bootVersion.set(v);
this._remoteVersion.set(v);
}
}
private async poll(): Promise<void> {
const v = await this.fetchVersion();
if (v) this._remoteVersion.set(v);
}
private async fetchVersion(): Promise<string | null> {
try {
const resp = await firstValueFrom(
this.http.get<{ version: string }>('/api/version'),
);
return resp?.version ?? null;
} catch {
// Backend down / restart en cours — on ignore, on retentera au prochain tick
return null;
}
}
}

View File

@@ -221,58 +221,205 @@
</div> </div>
</section> </section>
<!-- Bloc Mises a jour --> <!-- Bloc Mises a jour (canal stable + canal beta Patreon fusionnes) -->
<section class="card" *ngIf="config.updateCheckEnabled"> <section class="card" *ngIf="config.updateCheckEnabled || licenseStatus?.enabled">
<h2>Mises a jour</h2> <h2>Mises a jour</h2>
<p class="hint">Verifie aupres du registry Docker si une nouvelle version <p class="hint">Verifie aupres du registry Docker si une nouvelle version
des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont
exclus — ils sont mis a jour manuellement.</p> exclus — ils sont mis a jour manuellement.</p>
<div class="form-row"> <!-- ====================================================== -->
<button type="button" class="btn-secondary" (click)="checkUpdates()" [disabled]="updateChecking"> <!-- Sous-section : canal stable -->
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon> <!-- ====================================================== -->
<span>{{ updateChecking ? 'Verification...' : 'Verifier maintenant' }}</span> <div class="channel-block" *ngIf="config.updateCheckEnabled">
</button> <h3 class="channel-title">Canal stable</h3>
</div>
<div *ngIf="updateStatus && !updateStatus.enabled" class="hint"> <div class="form-row">
Feature non configuree (WATCHTOWER_TOKEN absent). <button type="button" class="btn-secondary" (click)="checkUpdates()" [disabled]="updateChecking">
</div> <lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ updateChecking ? 'Verification...' : 'Verifier maintenant' }}</span>
<div *ngIf="updateStatus?.enabled">
<div *ngIf="updateStatus?.updateAvailable" class="alert alert-success">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une mise a jour est disponible.</span>
</div>
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Verification impossible pour certaines images — voir details ci-dessous.</span>
</div>
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
</div>
<ul class="update-images" *ngIf="updateStatus?.images?.length">
<li *ngFor="let img of updateStatus?.images">
<strong>{{ img.image }}</strong>
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">MAJ dispo</span>
<span *ngIf="img.status === 'UP_TO_DATE'" class="badge-ok">a jour</span>
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn"
title="Impossible de comparer (baseline absente ou registry injoignable)">verification impossible</span>
</li>
</ul>
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>{{ updateApplying ? 'Mise a jour en cours...' : 'Mettre a jour maintenant' }}</span>
</button> </button>
</div> </div>
<div *ngIf="updateMessage" class="alert alert-success"> <div *ngIf="updateStatus && !updateStatus.enabled" class="hint">
<lucide-icon [img]="Check" [size]="16"></lucide-icon> Feature non configuree (WATCHTOWER_TOKEN absent).
<span>{{ updateMessage }}</span>
</div> </div>
<div *ngIf="updateStatus?.enabled">
<div *ngIf="updateStatus?.updateAvailable" class="alert alert-success">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une mise a jour est disponible.</span>
</div>
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Verification impossible pour certaines images — voir details ci-dessous.</span>
</div>
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
</div>
<ul class="update-images" *ngIf="updateStatus?.images?.length">
<li *ngFor="let img of updateStatus?.images">
<strong>{{ img.image }}</strong>
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">MAJ dispo</span>
<span *ngIf="img.status === 'UP_TO_DATE'" class="badge-ok">a jour</span>
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn"
title="Impossible de comparer (baseline absente ou registry injoignable)">verification impossible</span>
</li>
</ul>
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>{{ updateApplying ? 'Mise a jour en cours...' : 'Mettre a jour maintenant' }}</span>
</button>
</div>
<div *ngIf="updateMessage" class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ updateMessage }}</span>
</div>
</div>
</div>
<!-- ====================================================== -->
<!-- Sous-section : canal beta (Patreon) -->
<!-- ====================================================== -->
<div class="channel-block" *ngIf="licenseStatus?.enabled">
<h3 class="channel-title">
<lucide-icon [img]="Heart" [size]="16"></lucide-icon>
Canal beta &mdash; reserve aux patrons
</h3>
<p class="hint">
Soutiens LoreMind sur Patreon pour acceder aux nouvelles features en avant-premiere.
Le tier <strong>Compagnon</strong> (7&euro;/mois) ou superieur debloque ce canal.
</p>
<!-- Pas de licence installee -->
<ng-container *ngIf="licenseStatus?.status === 'NONE'">
<div class="form-row">
<button type="button" class="btn-primary" (click)="connectPatreon()">
<lucide-icon [img]="Link2" [size]="16"></lucide-icon>
<span>Connecter mon compte Patreon</span>
</button>
</div>
<p class="hint">
Une nouvelle fenetre va s'ouvrir vers Patreon. Apres autorisation, copie le token affiche
et colle-le ci-dessous.
</p>
<div class="form-row">
<label for="license-jwt">Token Patreon</label>
<input
id="license-jwt"
type="text"
[(ngModel)]="licenseJwtInput"
placeholder="eyJhbGciOiJFZERTQS..."
autocomplete="off"
>
</div>
<div class="form-row">
<button type="button" class="btn-primary" (click)="installLicense()" [disabled]="!licenseJwtInput.trim()">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>Activer la licence</span>
</button>
</div>
<div *ngIf="licenseError" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ licenseError }}</span>
</div>
</ng-container>
<!-- Licence installee (VALID / GRACE / EXPIRED / UNVERIFIABLE) -->
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'">
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>Compte Patreon connecte. Tier {{ licenseStatus.tierId }} actif.</span>
</div>
<div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>
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".
</span>
</div>
<div *ngIf="licenseStatus.status === 'EXPIRED'" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>
Connexion Patreon expiree depuis trop longtemps. Reconnecte-toi pour retrouver l'acces beta.
</span>
</div>
<div *ngIf="licenseStatus.status === 'UNVERIFIABLE'" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>Le token installe ne peut plus etre verifie. Reconnecte-toi.</span>
</div>
<ul class="license-info">
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ licenseStatus.tierId }}</li>
<li *ngIf="licenseStatus.expiresAt">
<strong>Validite :</strong>
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
<span *ngIf="daysUntilExpiry !== null && daysUntilExpiry > 0">
(renouvellement dans {{ daysUntilExpiry }} jour<span *ngIf="daysUntilExpiry > 1">s</span>)
</span>
</li>
<li *ngIf="licenseStatus.lastRefreshAttemptAt">
<strong>Dernier refresh :</strong>
{{ formatDate(licenseStatus.lastRefreshAttemptAt) }}
<span *ngIf="licenseStatus.lastRefreshSucceeded === true" class="badge-ok">OK</span>
<span *ngIf="licenseStatus.lastRefreshSucceeded === false" class="badge-warn">echec</span>
</li>
</ul>
<div class="form-row form-row-inline">
<label class="checkbox">
<input
type="checkbox"
[checked]="licenseStatus.betaChannelEnabled"
(change)="toggleBetaChannel(!licenseStatus.betaChannelEnabled)"
[disabled]="licenseStatus.status !== 'VALID' && licenseStatus.status !== 'GRACE'"
>
<span>Activer le canal beta</span>
</label>
</div>
<div class="form-row form-row-actions">
<button type="button" class="btn-secondary" (click)="refreshLicense()" [disabled]="licenseLoading">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ licenseLoading ? 'Verification...' : 'Verifier maintenant' }}</span>
</button>
<button type="button" class="btn-secondary btn-danger" (click)="disconnectPatreon()">
<lucide-icon [img]="Unlink" [size]="14"></lucide-icon>
<span>Deconnecter Patreon</span>
</button>
</div>
<!-- Etat du canal beta -->
<div *ngIf="licenseStatus.betaChannelEnabled" class="beta-status">
<div *ngIf="betaChecking" class="hint">Verification des images beta...</div>
<div *ngIf="!betaChecking && betaStatus && !betaStatus.enabled" class="hint">
Indisponible : {{ betaStatus.disabledReason }}
</div>
<div *ngIf="!betaChecking && betaStatus?.enabled">
<div *ngIf="betaStatus?.updateAvailable" class="alert alert-success">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une version beta est disponible. Pour l'installer, modifie ton fichier <code>.env</code> :
<code>IMAGE_NAMESPACE=igmlcreation/loremind-beta-</code> puis
<code>docker compose pull &amp;&amp; docker compose up -d</code>.</span>
</div>
<div *ngIf="betaStatus?.anyUnknown && !betaStatus?.updateAvailable" class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>Verification beta impossible pour certaines images.</span>
</div>
<ul class="update-images" *ngIf="betaStatus?.images?.length">
<li *ngFor="let img of betaStatus?.images">
<strong>{{ img.image }}</strong>
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">version dispo</span>
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn">verification impossible</span>
</li>
</ul>
</div>
</div>
</ng-container>
</div> </div>
</section> </section>

View File

@@ -364,3 +364,103 @@
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
border-radius: 3px; 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;
}
}

View File

@@ -2,11 +2,12 @@ import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; 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 { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { UpdatesService, UpdateStatus } from '../services/updates.service'; import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service'; import { ConfigService } from '../services/config.service';
import { LicenseService, LicenseStatusDTO, BetaStatusDTO } from '../services/license.service';
/** /**
* Ecran de parametrage du LLM utilise par le Brain. * Ecran de parametrage du LLM utilise par le Brain.
@@ -37,6 +38,19 @@ export class SettingsComponent implements OnInit {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Plus = Plus; readonly Plus = Plus;
readonly X = X; 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 --- // --- Pull / delete de modeles Ollama ---
/** Dialog d'ajout de modele ouvert/ferme. */ /** Dialog d'ajout de modele ouvert/ferme. */
@@ -105,7 +119,8 @@ export class SettingsComponent implements OnInit {
private settingsService: SettingsService, private settingsService: SettingsService,
private router: Router, private router: Router,
private updatesService: UpdatesService, private updatesService: UpdatesService,
public config: ConfigService public config: ConfigService,
private licenseService: LicenseService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -113,6 +128,117 @@ export class SettingsComponent implements OnInit {
if (this.config.updateCheckEnabled) { if (this.config.updateCheckEnabled) {
this.checkUpdates(); 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 { checkUpdates(): void {

View File

@@ -0,0 +1,29 @@
<div
class="update-banner"
*ngIf="versionChecker.hasUpdate() && !dismissed"
role="status"
aria-live="polite"
>
<div class="banner-content">
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
<span>
Une nouvelle version de LoreMind est disponible
(<strong>{{ versionChecker.remoteVersion() }}</strong>).
Recharge pour profiter des dernieres ameliorations.
</span>
</div>
<div class="banner-actions">
<button type="button" class="btn-reload" (click)="reload()">
Recharger
</button>
<button
type="button"
class="btn-dismiss"
(click)="dismiss()"
title="Fermer (sera reaffiche au prochain demarrage)"
aria-label="Fermer"
>
<lucide-icon [img]="X" [size]="16"></lucide-icon>
</button>
</div>
</div>

View File

@@ -0,0 +1,68 @@
.update-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 20px;
background: linear-gradient(90deg, #6d28d9, #7c3aed);
color: #fff;
font-size: 0.9rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
.banner-content {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.banner-actions {
display: flex;
align-items: center;
gap: 8px;
}
.btn-reload {
background: #fff;
color: #6d28d9;
border: 0;
padding: 6px 14px;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
&:hover {
background: rgba(255, 255, 255, 0.92);
}
}
.btn-dismiss {
background: transparent;
border: 0;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
&:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
}
}
// Decale le contenu de l'app vers le bas quand le bandeau est present
// (bandeau fixed, ne pousse pas naturellement le layout).
:host:has(.update-banner) {
display: block;
}

View File

@@ -0,0 +1,34 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, RefreshCw, X } from 'lucide-angular';
import { VersionCheckerService } from '../../services/version-checker.service';
/**
* Bandeau global affiche en haut de l'app quand une nouvelle version de
* LoreMind a ete deployee pendant que l'utilisateur avait deja l'onglet
* ouvert. Propose un reload en un clic.
*/
@Component({
selector: 'app-update-banner',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './update-banner.component.html',
styleUrls: ['./update-banner.component.scss']
})
export class UpdateBannerComponent {
readonly RefreshCw = RefreshCw;
readonly X = X;
/** L'utilisateur a explicitement ferme le bandeau pour cette session. */
dismissed = false;
constructor(public versionChecker: VersionCheckerService) {}
reload(): void {
this.versionChecker.reload();
}
dismiss(): void {
this.dismissed = true;
}
}