Compare commits
10 Commits
9ad7651c44
...
v0.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
| efaf5a3794 | |||
| 4fe93b5ff3 | |||
| 0f2d1b1efe | |||
| 5ff05242a8 | |||
| b06c77a1eb | |||
| 03bc669efe | |||
| c3873ddd84 | |||
| d7ceeac1b0 | |||
| cdbd3cd9b4 | |||
| a708c74425 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
25
core/pom.xml
25
core/pom.xml
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.7.0</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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,120 +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.
|
||||||
* - {@link #check()} re-interroge le registry et compare. Si un digest a
|
|
||||||
* change, une mise a jour est disponible.
|
|
||||||
* - {@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;
|
||||||
@PostConstruct
|
this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null;
|
||||||
void initBaseline() {
|
log.info("Update check init - registry={} images={} currentVersion={}",
|
||||||
if (!isEnabled()) {
|
this.registry, this.images, this.currentVersion);
|
||||||
log.info("Update check disabled (WATCHTOWER_TOKEN not set)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
|
|
||||||
for (String image : images) {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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, 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;
|
||||||
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());
|
||||||
}
|
}
|
||||||
// Si on n'a pas de baseline (echec au boot), on l'aligne maintenant
|
ImageStatusKind kind;
|
||||||
// pour eviter un faux positif "MAJ dispo".
|
if (latest == null) {
|
||||||
if (baseline == null && remote != null) {
|
kind = ImageStatusKind.UNKNOWN;
|
||||||
baselineDigests.put(image, remote);
|
anyUnknown = true;
|
||||||
baseline = remote;
|
} else {
|
||||||
|
int cmp = compareSemver(currentVersion, latest);
|
||||||
|
if (cmp >= 0) {
|
||||||
|
kind = ImageStatusKind.UP_TO_DATE;
|
||||||
|
} else {
|
||||||
|
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||||
|
anyUpdate = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
boolean updateAvailable = baseline != null && remote != null && !baseline.equals(remote);
|
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
|
||||||
if (updateAvailable) anyUpdate = true;
|
|
||||||
statuses.add(new ImageStatus(image, baseline, remote, updateAvailable));
|
|
||||||
}
|
}
|
||||||
return new UpdateStatus(true, anyUpdate, statuses, Instant.now());
|
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;
|
||||||
|
} else {
|
||||||
|
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||||
|
anyUpdate = true;
|
||||||
|
}
|
||||||
|
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
|
||||||
|
}
|
||||||
|
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void apply() {
|
public void apply() {
|
||||||
@@ -141,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,
|
||||||
@@ -153,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;
|
||||||
@@ -198,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");
|
||||||
@@ -275,18 +408,52 @@ public class UpdateCheckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Records de retour (sortis sous forme JSON par Jackson)
|
// Records / DTO
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
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,
|
||||||
|
String currentVersion,
|
||||||
List<ImageStatus> images,
|
List<ImageStatus> images,
|
||||||
Instant checkedAt) {}
|
Instant checkedAt) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statut par image. {@code localVersion} = version embarquee dans le binaire ;
|
||||||
|
* {@code remoteVersion} = plus haute version semver trouvee dans le registry.
|
||||||
|
* {@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,
|
||||||
boolean updateAvailable) {}
|
ImageStatusKind status,
|
||||||
|
boolean updateAvailable) {
|
||||||
|
|
||||||
|
public ImageStatus(String image, String localVersion, String remoteVersion, ImageStatusKind status) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 -> {});
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
29
core/src/main/resources/licensing/README.md
Normal file
29
core/src/main/resources/licensing/README.md
Normal 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)
|
||||||
3
core/src/main/resources/licensing/jwt-public-key.pem
Normal file
3
core/src/main/resources/licensing/jwt-public-key.pem
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEArbfaqBq54HJR1pKqliTShKrNIab32gpBwSTDw90I4wg=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package com.loremind.infrastructure.updates;
|
||||||
|
|
||||||
|
import com.loremind.application.licensing.LicenseService;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests UpdateCheckService - approche semver (post-refactor v0.8.x).
|
||||||
|
*
|
||||||
|
* Couvre :
|
||||||
|
* - feature desactivee si WATCHTOWER_TOKEN absent
|
||||||
|
* - UP_TO_DATE quand version locale == max(tags remote)
|
||||||
|
* - UPDATE_AVAILABLE quand un tag plus eleve existe
|
||||||
|
* - UNKNOWN quand le registry echoue
|
||||||
|
* - UNKNOWN quand BuildProperties est absent (currentVersion = null)
|
||||||
|
* - parseSemver / findMaxSemver / compareSemver utilitaires
|
||||||
|
*/
|
||||||
|
public class UpdateCheckServiceTest {
|
||||||
|
|
||||||
|
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(
|
||||||
|
new RestTemplateBuilder(),
|
||||||
|
"ghcr.io",
|
||||||
|
"igmlcreation/loremind-core,igmlcreation/loremind-brain",
|
||||||
|
"http://watchtower:8080",
|
||||||
|
token,
|
||||||
|
"",
|
||||||
|
null,
|
||||||
|
bp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RestTemplate stubHttp(UpdateCheckService svc) {
|
||||||
|
RestTemplate http = mock(RestTemplate.class);
|
||||||
|
ReflectionTestUtils.setField(svc, "http", http);
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void stubTags(RestTemplate http, String image, List<String> tags) {
|
||||||
|
TagsListResponse body = new TagsListResponse();
|
||||||
|
body.name = image;
|
||||||
|
body.tags = tags;
|
||||||
|
ResponseEntity<TagsListResponse> resp = new ResponseEntity<>(body, HttpStatus.OK);
|
||||||
|
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
|
||||||
|
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
|
||||||
|
.thenReturn(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void stubTagsFailure(RestTemplate http, String image) {
|
||||||
|
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
|
||||||
|
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
|
||||||
|
.thenThrow(new RuntimeException("network down"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Comportement du service
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledWhenTokenMissing() {
|
||||||
|
UpdateCheckService svc = newService("", "0.8.0");
|
||||||
|
UpdateStatus status = svc.check();
|
||||||
|
assertFalse(status.enabled());
|
||||||
|
assertFalse(status.updateAvailable());
|
||||||
|
assertFalse(status.anyUnknown());
|
||||||
|
assertTrue(status.images().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upToDate_whenCurrentEqualsMaxRemote() {
|
||||||
|
UpdateCheckService svc = newService("token", "0.8.0");
|
||||||
|
RestTemplate http = stubHttp(svc);
|
||||||
|
stubTags(http, "igmlcreation/loremind-core",
|
||||||
|
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();
|
||||||
|
|
||||||
|
assertTrue(status.enabled());
|
||||||
|
assertFalse(status.updateAvailable());
|
||||||
|
assertFalse(status.anyUnknown());
|
||||||
|
assertEquals("0.8.0", status.currentVersion());
|
||||||
|
for (ImageStatus img : status.images()) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateAvailable_whenRemoteHigher() {
|
||||||
|
UpdateCheckService svc = newService("token", "0.7.2");
|
||||||
|
RestTemplate http = stubHttp(svc);
|
||||||
|
stubTags(http, "igmlcreation/loremind-core",
|
||||||
|
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();
|
||||||
|
|
||||||
|
assertTrue(status.updateAvailable());
|
||||||
|
assertFalse(status.anyUnknown());
|
||||||
|
|
||||||
|
ImageStatus core = status.images().stream()
|
||||||
|
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||||
|
assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status());
|
||||||
|
assertEquals("0.7.2", core.localVersion());
|
||||||
|
assertEquals("0.8.0", core.remoteVersion());
|
||||||
|
assertTrue(core.updateAvailable(), "back-compat bool");
|
||||||
|
|
||||||
|
ImageStatus brain = status.images().stream()
|
||||||
|
.filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow();
|
||||||
|
assertEquals(ImageStatusKind.UP_TO_DATE, brain.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknown_whenRegistryFails() {
|
||||||
|
UpdateCheckService svc = newService("token", "0.8.0");
|
||||||
|
RestTemplate http = stubHttp(svc);
|
||||||
|
stubTagsFailure(http, "igmlcreation/loremind-core");
|
||||||
|
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();
|
||||||
|
|
||||||
|
assertTrue(status.enabled());
|
||||||
|
assertFalse(status.updateAvailable());
|
||||||
|
assertTrue(status.anyUnknown());
|
||||||
|
assertNull(status.currentVersion());
|
||||||
|
for (ImageStatus img : status.images()) {
|
||||||
|
assertEquals(ImageStatusKind.UNKNOWN, img.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Utilitaires semver
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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
|
||||||
|
void parseSemver_rejectsInvalid() {
|
||||||
|
assertNull(UpdateCheckService.parseSemver(null));
|
||||||
|
assertNull(UpdateCheckService.parseSemver(""));
|
||||||
|
assertNull(UpdateCheckService.parseSemver("latest"));
|
||||||
|
assertNull(UpdateCheckService.parseSemver("stable"));
|
||||||
|
assertNull(UpdateCheckService.parseSemver("0.8.0.1.2"));
|
||||||
|
assertNull(UpdateCheckService.parseSemver("0.x.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compareSemver_basic() {
|
||||||
|
assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0);
|
||||||
|
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0);
|
||||||
|
assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0"));
|
||||||
|
assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0"));
|
||||||
|
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0);
|
||||||
|
assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findMaxSemver_returnsNullWhenNoValidTag() {
|
||||||
|
assertNull(UpdateCheckService.findMaxSemver(List.of("latest", "stable", "main")));
|
||||||
|
assertNull(UpdateCheckService.findMaxSemver(List.of()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.0
|
Version : 0.8.1
|
||||||
|
|
||||||
.LINK
|
.LINK
|
||||||
https://github.com/IGMLcreation/LoreMind
|
https://github.com/IGMLcreation/LoreMind
|
||||||
|
|||||||
@@ -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
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.7.0",
|
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.7.0",
|
"version": "0.8.1",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.'
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
94
web/src/app/services/license.service.ts
Normal file
94
web/src/app/services/license.service.ts
Normal 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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,18 +3,31 @@ 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.
|
||||||
|
* Comparaison faite par version semver (BuildProperties cote backend), plus
|
||||||
|
* fiable que les digests qui ne survivaient pas a un restart sans pull.
|
||||||
*/
|
*/
|
||||||
|
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;
|
||||||
|
/** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStatus {
|
export interface UpdateStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** True si au moins une image a status === 'UPDATE_AVAILABLE'. */
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
|
/** True si au moins une image a status === 'UNKNOWN'. */
|
||||||
|
anyUnknown: boolean;
|
||||||
|
/** Version courante du binaire (BuildProperties). null si build-info absent. */
|
||||||
|
currentVersion: string | null;
|
||||||
images: ImageStatus[];
|
images: ImageStatus[];
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
94
web/src/app/services/version-checker.service.ts
Normal file
94
web/src/app/services/version-checker.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -221,53 +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?.updateAvailable" 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.updateAvailable" class="badge-update">MAJ dispo</span>
|
|
||||||
<span *ngIf="!img.updateAvailable && img.remoteDigest" class="badge-ok">a jour</span>
|
|
||||||
<span *ngIf="!img.remoteDigest" class="badge-warn">indisponible</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 — 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€/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 && 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>
|
||||||
|
|
||||||
|
|||||||
@@ -303,6 +303,7 @@
|
|||||||
}
|
}
|
||||||
.alert-error { background: rgba(220, 80, 80, 0.15); color: #ff9b9b; }
|
.alert-error { background: rgba(220, 80, 80, 0.15); color: #ff9b9b; }
|
||||||
.alert-success { background: rgba(80, 200, 120, 0.15); color: #a2e8b6; }
|
.alert-success { background: rgba(80, 200, 120, 0.15); color: #a2e8b6; }
|
||||||
|
.alert-warn { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
|
||||||
|
|
||||||
/* --- Slider fenetre de contexte -------------------------------------- */
|
/* --- Slider fenetre de contexte -------------------------------------- */
|
||||||
.ctx-value {
|
.ctx-value {
|
||||||
@@ -363,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
34
web/src/app/shared/update-banner/update-banner.component.ts
Normal file
34
web/src/app/shared/update-banner/update-banner.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user