Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Failing after 48s
Build & Push Images / build (core) (push) Failing after 1m18s
Build & Push Images / build (web) (push) Successful in 1m35s

This commit is contained in:
2026-04-28 18:56:28 +02:00
parent b06c77a1eb
commit 5ff05242a8
35 changed files with 2134 additions and 50 deletions

5
.gitignore vendored
View File

@@ -97,3 +97,8 @@ loremind-docs/
# Docker Compose override (dev uniquement, non-distribue aux end users)
# ============================================================================
docker-compose.override.yml
# ============================================================================
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
# ============================================================================
relay/

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.7.2</version>
<version>0.8.0</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
@@ -83,6 +83,19 @@
<artifactId>minio</artifactId>
<version>8.5.11</version>
</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>
<build>

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
package com.loremind.domain.licensing;
import lombok.Builder;
import lombok.Data;
import java.time.Instant;
/**
* Licence Patreon installee dans cette instance LoreMind.
* <p>
* Singleton (une seule licence par instance, identifiee logiquement par
* {@code id = "current"}). Contient le JWT brut emis par le relais OAuth
* + les claims extraits a la verification, plus l'etat operationnel
* (derniere tentative de refresh, succes/echec).
* <p>
* <b>Note securite :</b> {@link #rawJwt} est stocke tel quel ; sa signature
* Ed25519 est verifiee a chaque lecture. Pas besoin de chiffrement au repos
* supplementaire — un attaquant qui a acces a la base a deja l'instance,
* et le JWT ne donne aucun pouvoir au-dela du canal beta de cette instance.
*/
@Data
@Builder
public class License {
private String id;
private String rawJwt;
private String patreonUserId;
private String tierId;
private String instanceId;
private Instant issuedAt;
private Instant expiresAt;
private Instant lastRefreshAttemptAt;
private boolean lastRefreshSucceeded;
private boolean betaChannelEnabled;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.licensing;
import java.time.Instant;
/**
* Claims extraits d'un JWT licence apres verification de signature.
* Immuable.
*/
public record LicenseClaims(
String subject,
String tierId,
String instanceId,
Instant issuedAt,
Instant expiresAt
) {}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.licensing;
import java.time.Instant;
/**
* Vue immuable de la licence pour exposition vers les couches superieures.
* Decouple le domaine du DTO web et permet de calculer le {@link LicenseStatus}
* a un instant donne sans muter l'entite.
*/
public record LicenseSnapshot(
LicenseStatus status,
String patreonUserId,
String tierId,
String instanceId,
Instant expiresAt,
Instant lastRefreshAttemptAt,
boolean lastRefreshSucceeded,
boolean betaChannelEnabled
) {
public static LicenseSnapshot none() {
return new LicenseSnapshot(LicenseStatus.NONE, null, null, null, null, null, false, false);
}
}

View File

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

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.licensing;
import java.time.Instant;
/**
* Credentials de pull pour un registry Docker, distribues par le relais
* apres verification d'un JWT licence valide.
* <p>
* {@code expiresAt} peut etre {@code null} si le credential est statique
* (cas du PAT GHCR partage en MVP) ; sinon, l'instance doit re-demander
* de nouveaux credentials avant cette date.
*/
public record RegistryCredentials(
String registry,
String username,
String password,
Instant expiresAt
) {}

View File

@@ -0,0 +1,34 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.RegistryCredentials;
import java.io.IOException;
/**
* Port de sortie : ecriture du docker config.json partage avec Watchtower.
* <p>
* Le fichier sert a Watchtower pour s'authentifier au registry prive (GHCR)
* lors du pull des images du canal beta. Volume Docker {@code docker-config}
* monte sur Core (en ecriture) et sur Watchtower (en lecture, via la variable
* {@code DOCKER_CONFIG}).
*/
public interface DockerConfigWriter {
/**
* Ecrit ou met a jour les credentials pour le registry indique.
* Cree le fichier s'il n'existe pas, conserve les autres registries deja
* presents (en theorie : aucun, mais defensif).
*/
void writeCredentials(RegistryCredentials credentials) throws IOException;
/**
* Supprime le fichier de credentials. Appele quand la licence est invalidee
* ou que le toggle beta passe a OFF.
*/
void clear() throws IOException;
/**
* @return true si le fichier de creds existe actuellement.
*/
boolean isPresent();
}

View File

@@ -0,0 +1,34 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.LicenseClaims;
/**
* Port de sortie : verification de signature et extraction des claims
* d'un JWT emis par le relais.
* <p>
* Implemente cote infrastructure avec la cle publique Ed25519 embarquee
* (SPKI PEM via configuration {@code licensing.jwt.public-key}).
*/
public interface JwtVerifier {
/**
* Verifie la signature, l'issuer, l'audience et l'expiration du JWT.
* @throws JwtVerificationException si la signature est invalide ou les claims malformes
*/
LicenseClaims verify(String rawJwt) throws JwtVerificationException;
/**
* @return true si la cle publique est configuree et utilisable.
* Permet a l'application de masquer la feature licensing si pas configuree.
*/
boolean isConfigured();
class JwtVerificationException extends Exception {
public JwtVerificationException(String message) {
super(message);
}
public JwtVerificationException(String message, Throwable cause) {
super(message, cause);
}
}
}

View File

@@ -0,0 +1,59 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.RegistryCredentials;
/**
* Port de sortie vers le service relais OAuth Patreon.
* Encapsule les appels HTTP : refresh JWT et fetch registry credentials.
*/
public interface LicenseRelay {
/**
* Demande au relais l'URL OAuth a ouvrir pour connecter le compte Patreon.
*/
String buildConnectUrl(String instanceId);
/**
* Demande au relais de renouveler un JWT existant. Le relais re-verifie
* le tier Patreon de l'utilisateur ; renvoie un nouveau JWT si toujours
* actif, ou leve {@link RelayException} sinon.
*/
String refreshToken(String currentJwt) throws RelayException;
/**
* Demande au relais les credentials de pull du registry beta.
*/
RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException;
/**
* Erreurs distinctes emises par le relais. Permet au service application
* de differencier "tier expire" (action utilisateur) de "relais down"
* (action transitoire, garde la grace period).
*/
class RelayException extends Exception {
private final RelayErrorKind kind;
public RelayException(RelayErrorKind kind, String message) {
super(message);
this.kind = kind;
}
public RelayException(RelayErrorKind kind, String message, Throwable cause) {
super(message, cause);
this.kind = kind;
}
public RelayErrorKind getKind() {
return kind;
}
}
enum RelayErrorKind {
/** Le relais est joignable mais refuse : tier non actif, JWT trop ancien, etc. */
REJECTED,
/** Le relais a renvoye un JWT mais il est invalide / non parsable. */
BAD_RESPONSE,
/** Le relais est injoignable / 5xx / timeout. */
TRANSIENT
}
}

View File

@@ -0,0 +1,19 @@
package com.loremind.domain.licensing.ports;
import com.loremind.domain.licensing.License;
import java.util.Optional;
/**
* Port de sortie pour la persistance de la licence installee.
* <p>
* Une seule licence par instance ({@code id = "current"} par convention).
*/
public interface LicenseRepository {
Optional<License> findCurrent();
License save(License license);
void deleteCurrent();
}

View File

@@ -0,0 +1,111 @@
package com.loremind.infrastructure.licensing;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.DockerConfigWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Base64;
/**
* Implementation : ecriture du fichier {@code config.json} au format Docker
* standard, dans un volume partage avec Watchtower.
* <p>
* Format produit :
* <pre>{@code
* {
* "auths": {
* "ghcr.io": {
* "auth": "<base64(username:password)>"
* }
* }
* }
* }</pre>
*/
@Component
public class FileDockerConfigWriter implements DockerConfigWriter {
private static final Logger log = LoggerFactory.getLogger(FileDockerConfigWriter.class);
private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper();
public FileDockerConfigWriter(
@Value("${licensing.docker-config-path:/shared/docker/config.json}") String pathStr) {
this.configPath = Path.of(pathStr);
}
@Override
public void writeCredentials(RegistryCredentials credentials) throws IOException {
ensureParentDirectory();
ObjectNode root;
if (Files.exists(configPath)) {
try {
JsonNode existing = mapper.readTree(configPath.toFile());
root = existing.isObject() ? (ObjectNode) existing : mapper.createObjectNode();
} catch (IOException e) {
log.warn("Existing docker config unreadable, overwriting: {}", e.getMessage());
root = mapper.createObjectNode();
}
} else {
root = mapper.createObjectNode();
}
ObjectNode auths = root.has("auths") && root.get("auths").isObject()
? (ObjectNode) root.get("auths")
: root.putObject("auths");
String b64 = Base64.getEncoder().encodeToString(
(credentials.username() + ":" + credentials.password()).getBytes(StandardCharsets.UTF_8));
ObjectNode entry = mapper.createObjectNode();
entry.put("auth", b64);
auths.set(credentials.registry(), entry);
Files.writeString(configPath, mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root),
StandardCharsets.UTF_8);
applyRestrictivePermissions();
log.info("Docker config written at {} for registry {}", configPath, credentials.registry());
}
@Override
public void clear() throws IOException {
if (Files.exists(configPath)) {
Files.delete(configPath);
log.info("Docker config cleared at {}", configPath);
}
}
@Override
public boolean isPresent() {
return Files.exists(configPath);
}
private void ensureParentDirectory() throws IOException {
Path parent = configPath.getParent();
if (parent != null && !Files.exists(parent)) {
Files.createDirectories(parent);
}
}
/** 0600 sur POSIX. Sur Windows (dev), no-op silencieux. */
private void applyRestrictivePermissions() {
try {
Files.setPosixFilePermissions(configPath, PosixFilePermissions.fromString("rw-------"));
} catch (UnsupportedOperationException | IOException e) {
// Windows / FS qui ne supporte pas POSIX => ignore (le conteneur tourne sous Linux en prod)
}
}
}

View File

@@ -0,0 +1,146 @@
package com.loremind.infrastructure.licensing;
import com.fasterxml.jackson.databind.JsonNode;
import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.LicenseRelay;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
/**
* Client HTTP du relais OAuth Patreon (deploye sur Cloudflare Workers).
* Voir {@code relay/} pour le code du relais.
*/
@Component
public class HttpLicenseRelay implements LicenseRelay {
private static final Logger log = LoggerFactory.getLogger(HttpLicenseRelay.class);
private final RestTemplate http;
private final String baseUrl;
public HttpLicenseRelay(
RestTemplateBuilder builder,
@Value("${licensing.relay.base-url:}") String baseUrl) {
this.http = builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15))
.build();
this.baseUrl = stripTrailingSlash(baseUrl);
}
@Override
public String buildConnectUrl(String instanceId) {
if (baseUrl.isBlank()) {
throw new IllegalStateException("Licensing relay base URL not configured");
}
String encoded = URLEncoder.encode(instanceId, StandardCharsets.UTF_8);
return baseUrl + "/oauth/start?instance_id=" + encoded;
}
@Override
public String refreshToken(String currentJwt) throws RelayException {
if (baseUrl.isBlank()) {
throw new RelayException(RelayErrorKind.TRANSIENT, "relay not configured");
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> body = Map.of("jwt", currentJwt);
ResponseEntity<JsonNode> resp;
try {
resp = http.exchange(
baseUrl + "/token/refresh",
HttpMethod.POST,
new HttpEntity<>(body, headers),
JsonNode.class);
} catch (HttpClientErrorException e) {
throw new RelayException(RelayErrorKind.REJECTED,
"relay rejected refresh: " + e.getStatusCode() + " " + e.getStatusText());
} catch (HttpServerErrorException e) {
throw new RelayException(RelayErrorKind.TRANSIENT,
"relay 5xx: " + e.getStatusCode());
} catch (RestClientException e) {
throw new RelayException(RelayErrorKind.TRANSIENT, "relay unreachable: " + e.getMessage(), e);
}
JsonNode payload = resp.getBody();
if (payload == null || !payload.hasNonNull("jwt")) {
throw new RelayException(RelayErrorKind.BAD_RESPONSE, "missing jwt in refresh response");
}
return payload.get("jwt").asText();
}
@Override
public RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException {
if (baseUrl.isBlank()) {
throw new RelayException(RelayErrorKind.TRANSIENT, "relay not configured");
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> body = Map.of("jwt", currentJwt);
ResponseEntity<JsonNode> resp;
try {
resp = http.exchange(
baseUrl + "/registry/credentials",
HttpMethod.POST,
new HttpEntity<>(body, headers),
JsonNode.class);
} catch (HttpClientErrorException e) {
throw new RelayException(RelayErrorKind.REJECTED,
"relay rejected creds: " + e.getStatusCode() + " " + e.getStatusText());
} catch (HttpServerErrorException e) {
throw new RelayException(RelayErrorKind.TRANSIENT,
"relay 5xx: " + e.getStatusCode());
} catch (RestClientException e) {
throw new RelayException(RelayErrorKind.TRANSIENT, "relay unreachable: " + e.getMessage(), e);
}
JsonNode payload = resp.getBody();
if (payload == null
|| !payload.hasNonNull("registry")
|| !payload.hasNonNull("username")
|| !payload.hasNonNull("password")) {
throw new RelayException(RelayErrorKind.BAD_RESPONSE, "incomplete credentials response");
}
Instant expiresAt = null;
if (payload.hasNonNull("expires_at")) {
try {
expiresAt = Instant.parse(payload.get("expires_at").asText());
} catch (Exception e) {
log.warn("Cannot parse expires_at from relay creds response: {}", e.getMessage());
}
}
return new RegistryCredentials(
payload.get("registry").asText(),
payload.get("username").asText(),
payload.get("password").asText(),
expiresAt
);
}
private static String stripTrailingSlash(String s) {
if (s == null) return "";
String v = s.trim();
if (v.endsWith("/")) v = v.substring(0, v.length() - 1);
return v;
}
}

View File

@@ -0,0 +1,93 @@
package com.loremind.infrastructure.licensing;
import com.loremind.application.licensing.LicenseService;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.domain.licensing.LicenseStatus;
import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.DockerConfigWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Optional;
/**
* Daemon planifie qui :
* <ul>
* <li>renouvelle le JWT licence via le relais avant expiration (J-2)</li>
* <li>met a jour les credentials registry GHCR pour Watchtower
* (volume partage docker-config) tant que le canal beta est ON</li>
* <li>nettoie les credentials si la licence est invalidee ou le toggle OFF</li>
* </ul>
* Idempotent : peut tourner toutes les 6h sans risque, fait du no-op
* la plupart du temps.
*/
@Component
public class LicenseRefreshDaemon {
private static final Logger log = LoggerFactory.getLogger(LicenseRefreshDaemon.class);
/** 6 heures entre chaque cycle. Suffisant pour rattraper un J-2 sans surcharger. */
private static final long FIXED_DELAY_MS = 6L * 60L * 60L * 1000L;
/** Premier run apres 30s pour laisser le contexte Spring se stabiliser. */
private static final long INITIAL_DELAY_MS = 30_000L;
private final LicenseService licenseService;
private final DockerConfigWriter dockerConfigWriter;
public LicenseRefreshDaemon(LicenseService licenseService,
DockerConfigWriter dockerConfigWriter) {
this.licenseService = licenseService;
this.dockerConfigWriter = dockerConfigWriter;
}
@Scheduled(initialDelay = INITIAL_DELAY_MS, fixedDelay = FIXED_DELAY_MS)
public void tick() {
if (!licenseService.isLicensingEnabled()) {
return;
}
try {
licenseService.refreshIfNeeded();
syncDockerConfig();
} catch (Exception e) {
log.error("LicenseRefreshDaemon tick failed: {}", e.getMessage(), e);
}
}
/**
* Aligne le fichier docker config avec l'etat de la licence et le toggle :
* <ul>
* <li>VALID/GRACE + beta ON -> ecrit/refresh les creds</li>
* <li>tout autre cas -> efface le fichier</li>
* </ul>
*/
private void syncDockerConfig() {
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
boolean shouldHaveCreds = snap.betaChannelEnabled()
&& (snap.status() == LicenseStatus.VALID || snap.status() == LicenseStatus.GRACE);
if (!shouldHaveCreds) {
try {
if (dockerConfigWriter.isPresent()) {
dockerConfigWriter.clear();
}
} catch (IOException e) {
log.warn("Cannot clear docker config: {}", e.getMessage());
}
return;
}
Optional<RegistryCredentials> creds = licenseService.fetchRegistryCredentials();
if (creds.isEmpty()) {
log.warn("Beta enabled but cannot fetch registry credentials (relay down or rejected)");
return;
}
try {
dockerConfigWriter.writeCredentials(creds.get());
} catch (IOException e) {
log.error("Cannot write docker config: {}", e.getMessage());
}
}
}

View File

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

View File

@@ -0,0 +1,72 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
/**
* Entite JPA pour la licence Patreon installee.
* <p>
* Singleton : une seule ligne par instance (id = "current"). Ce design permet
* de ne jamais avoir de licence "fantome" en base et de simplifier les queries.
*/
@Entity
@Table(name = "licenses")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LicenseJpaEntity {
@Id
private String id;
@Column(name = "raw_jwt", columnDefinition = "TEXT", nullable = false)
private String rawJwt;
@Column(name = "patreon_user_id", nullable = false)
private String patreonUserId;
@Column(name = "tier_id", nullable = false)
private String tierId;
@Column(name = "instance_id", nullable = false)
private String instanceId;
@Column(name = "issued_at", nullable = false)
private Instant issuedAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "last_refresh_attempt_at")
private Instant lastRefreshAttemptAt;
@Column(name = "last_refresh_succeeded", nullable = false)
private boolean lastRefreshSucceeded;
@Column(name = "beta_channel_enabled", nullable = false)
private boolean betaChannelEnabled;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
Instant now = Instant.now();
if (createdAt == null) createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
}

View File

@@ -0,0 +1,9 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.LicenseJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface LicenseJpaRepository extends JpaRepository<LicenseJpaEntity, String> {
}

View File

@@ -0,0 +1,76 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.licensing.License;
import com.loremind.domain.licensing.ports.LicenseRepository;
import com.loremind.infrastructure.persistence.entity.LicenseJpaEntity;
import com.loremind.infrastructure.persistence.jpa.LicenseJpaRepository;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.Optional;
@Repository
public class PostgresLicenseRepository implements LicenseRepository {
static final String CURRENT_ID = "current";
private final LicenseJpaRepository jpa;
public PostgresLicenseRepository(LicenseJpaRepository jpa) {
this.jpa = jpa;
}
@Override
public Optional<License> findCurrent() {
return jpa.findById(CURRENT_ID).map(this::toDomain);
}
@Override
public License save(License license) {
LicenseJpaEntity entity = toEntity(license);
if (entity.getCreatedAt() == null) {
entity.setCreatedAt(Instant.now());
}
LicenseJpaEntity saved = jpa.save(entity);
return toDomain(saved);
}
@Override
public void deleteCurrent() {
jpa.deleteById(CURRENT_ID);
}
private License toDomain(LicenseJpaEntity e) {
return License.builder()
.id(e.getId())
.rawJwt(e.getRawJwt())
.patreonUserId(e.getPatreonUserId())
.tierId(e.getTierId())
.instanceId(e.getInstanceId())
.issuedAt(e.getIssuedAt())
.expiresAt(e.getExpiresAt())
.lastRefreshAttemptAt(e.getLastRefreshAttemptAt())
.lastRefreshSucceeded(e.isLastRefreshSucceeded())
.betaChannelEnabled(e.isBetaChannelEnabled())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private LicenseJpaEntity toEntity(License l) {
return LicenseJpaEntity.builder()
.id(CURRENT_ID)
.rawJwt(l.getRawJwt())
.patreonUserId(l.getPatreonUserId())
.tierId(l.getTierId())
.instanceId(l.getInstanceId())
.issuedAt(l.getIssuedAt())
.expiresAt(l.getExpiresAt())
.lastRefreshAttemptAt(l.getLastRefreshAttemptAt())
.lastRefreshSucceeded(l.isLastRefreshSucceeded())
.betaChannelEnabled(l.isBetaChannelEnabled())
.createdAt(l.getCreatedAt())
.updatedAt(l.getUpdatedAt())
.build();
}
}

View File

@@ -1,5 +1,9 @@
package com.loremind.infrastructure.updates;
import com.loremind.application.licensing.LicenseService;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.domain.licensing.LicenseStatus;
import com.loremind.domain.licensing.RegistryCredentials;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -10,6 +14,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
@@ -19,9 +24,11 @@ import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
@@ -68,6 +75,9 @@ public class UpdateCheckService {
private final String tag;
private final String watchtowerUrl;
private final String watchtowerToken;
private final List<String> betaImages;
private final String betaTag;
private final LicenseService licenseService;
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>();
@@ -77,7 +87,10 @@ public class UpdateCheckService {
@Value("${update-check.images:}") String imagesCsv,
@Value("${update-check.tag:latest}") String tag,
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
@Value("${update-check.watchtower-token:}") String watchtowerToken) {
@Value("${update-check.watchtower-token:}") String watchtowerToken,
@Value("${licensing.beta.images:}") String betaImagesCsv,
@Value("${licensing.beta.tag:latest}") String betaTag,
LicenseService licenseService) {
this.http = builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15))
@@ -87,6 +100,9 @@ public class UpdateCheckService {
this.tag = tag;
this.watchtowerUrl = watchtowerUrl;
this.watchtowerToken = watchtowerToken;
this.betaImages = parseImages(betaImagesCsv);
this.betaTag = betaTag;
this.licenseService = licenseService;
}
/** Backoff progressif (ms) pour retry de baseline en cas d'echec initial. */
@@ -187,6 +203,118 @@ public class UpdateCheckService {
return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now());
}
/**
* Verifie l'etat du canal beta (images privees GHCR).
* Necessite licence valide/grace + toggle beta ON.
* Authentification basic auth via le PAT distribue par le relais.
*
* @return statut beta (peut etre {@link BetaStatus#disabled()} si licence absente,
* beta off ou licence expiree)
*/
public BetaStatus checkBeta() {
if (!licenseService.isLicensingEnabled()) {
return BetaStatus.disabled("licensing-not-configured");
}
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
if (snap.status() != LicenseStatus.VALID && snap.status() != LicenseStatus.GRACE) {
return BetaStatus.disabled("license-" + snap.status().name().toLowerCase());
}
if (!snap.betaChannelEnabled()) {
return BetaStatus.disabled("beta-toggle-off");
}
if (betaImages.isEmpty()) {
return BetaStatus.disabled("no-beta-images-configured");
}
Optional<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 remote = null;
try {
remote = fetchRemoteDigestAuth(betaRegistry, image, betaTag, basicAuth);
} catch (Exception e) {
log.warn("Beta check failed for {}: {}", image, e.getMessage());
}
// Pas de baseline pour la beta : on ne peut pas dire "a jour" car on
// ne sait pas quelle version le user fait tourner. On expose juste le
// digest remote ; l'UI affichera "version disponible : <tag>" sans
// comparaison locale tant qu'il n'y a pas un mecanisme de baseline.
ImageStatusKind kind = (remote == null) ? ImageStatusKind.UNKNOWN : ImageStatusKind.UPDATE_AVAILABLE;
if (kind == ImageStatusKind.UNKNOWN) anyUnknown = true;
else anyUpdate = true;
statuses.add(new ImageStatus(image, null, remote, kind));
}
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
}
private String fetchRemoteDigestAuth(String registryUrl, String image, String tagName, String authHeader) {
String url = registryUrl + "/v2/" + image + "/manifests/" + tagName;
HttpHeaders headers = new HttpHeaders();
headers.setAccept(MANIFEST_ACCEPT);
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
try {
return digestCall(url, headers);
} catch (HttpClientErrorException.Unauthorized e) {
// GHCR peut exiger d'echanger basic auth contre un bearer token via
// le challenge WWW-Authenticate. On reuse la logique existante en
// ajoutant l'auth header a la requete /token.
String www = e.getResponseHeaders() == null ? null
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
String token = obtainBearerTokenWithAuth(www, authHeader);
if (token == null) return null;
HttpHeaders bearerHeaders = new HttpHeaders();
bearerHeaders.setAccept(MANIFEST_ACCEPT);
bearerHeaders.setBearerAuth(token);
return digestCall(url, bearerHeaders);
}
}
@SuppressWarnings("rawtypes")
private String obtainBearerTokenWithAuth(@Nullable String wwwAuth, String authHeader) {
if (wwwAuth == null) return null;
String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
String realm = params.get("realm");
if (realm == null) return null;
StringBuilder url = new StringBuilder(realm);
boolean hasQuery = realm.contains("?");
for (String key : new String[]{"service", "scope"}) {
String v = params.get(key);
if (v != null) {
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
.replace("%3A", ":")
.replace("%2F", "/");
url.append(hasQuery ? '&' : '?').append(key).append('=').append(encoded);
hasQuery = true;
}
}
try {
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
ResponseEntity<Map> resp = http.exchange(url.toString(), HttpMethod.GET,
new HttpEntity<>(headers), Map.class);
Map<?, ?> body = resp.getBody();
if (body == null) return null;
Object t = body.get("token");
if (t == null) t = body.get("access_token");
return t == null ? null : t.toString();
} catch (Exception e) {
log.warn("Beta bearer token request failed: {}", e.getMessage());
return null;
}
}
public void apply() {
if (!isEnabled()) {
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
@@ -348,6 +476,29 @@ public class UpdateCheckService {
List<ImageStatus> images,
Instant checkedAt) {}
/**
* Etat du canal beta.
* <ul>
* <li>{@code enabled} : true si le canal beta est actif et la licence valide.</li>
* <li>{@code disabledReason} : si {@code enabled=false}, raison technique
* (licensing-not-configured, license-none, license-expired, beta-toggle-off,
* no-beta-images-configured, relay-unavailable). Permet a l'UI d'afficher
* un message contextuel.</li>
* </ul>
*/
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);
}
}
/**
* Le champ {@code updateAvailable} est conserve pour la compatibilite
* avec les anciens clients ; il est strictement derive de {@code status}

View File

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

View File

@@ -0,0 +1,87 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.licensing.LicenseService;
import com.loremind.application.licensing.LicenseService.InstallException;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* Endpoints de gestion de la licence Patreon.
*
* <ul>
* <li>{@code GET /api/license} : etat courant (status, tier, expiration...)</li>
* <li>{@code GET /api/license/connect-url} : URL OAuth a ouvrir dans le navigateur</li>
* <li>{@code POST /api/license/install} : colle un JWT recu du relais</li>
* <li>{@code DELETE /api/license} : deconnecte Patreon (efface la licence)</li>
* <li>{@code POST /api/license/refresh} : force un refresh manuel</li>
* <li>{@code PUT /api/license/beta-channel} : active/desactive le canal beta</li>
* </ul>
*/
@RestController
@RequestMapping("/api/license")
public class LicenseController {
private final LicenseService licenseService;
public LicenseController(LicenseService licenseService) {
this.licenseService = licenseService;
}
@GetMapping
public LicenseStatusDTO getStatus() {
boolean enabled = licenseService.isLicensingEnabled();
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
return LicenseStatusDTO.from(enabled, snap);
}
@GetMapping("/connect-url")
public Map<String, String> getConnectUrl() {
return Map.of("url", licenseService.buildConnectUrl());
}
@PostMapping("/install")
public ResponseEntity<?> install(@RequestBody InstallRequest request) {
if (request == null || request.jwt() == null || request.jwt().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "missing jwt"));
}
try {
LicenseSnapshot snap = licenseService.installToken(request.jwt());
return ResponseEntity.ok(LicenseStatusDTO.from(true, snap));
} catch (InstallException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@DeleteMapping
public ResponseEntity<Void> disconnect() {
licenseService.disconnect();
return ResponseEntity.noContent().build();
}
@PostMapping("/refresh")
public ResponseEntity<LicenseStatusDTO> refresh() {
licenseService.forceRefresh();
boolean enabled = licenseService.isLicensingEnabled();
return ResponseEntity.ok(LicenseStatusDTO.from(enabled, licenseService.getCurrentSnapshot()));
}
@PutMapping("/beta-channel")
public ResponseEntity<?> setBetaChannel(@RequestBody BetaChannelRequest request) {
if (request == null) {
return ResponseEntity.badRequest().body(Map.of("error", "missing body"));
}
try {
LicenseSnapshot snap = licenseService.setBetaChannelEnabled(request.enabled());
return ResponseEntity.ok(LicenseStatusDTO.from(true, snap));
} catch (IllegalStateException e) {
return ResponseEntity.status(409).body(Map.of("error", e.getMessage()));
}
}
public record InstallRequest(String jwt) {}
public record BetaChannelRequest(boolean enabled) {}
}

View File

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

View File

@@ -0,0 +1,35 @@
package com.loremind.infrastructure.web.dto.licensing;
import com.loremind.domain.licensing.LicenseSnapshot;
import java.time.Instant;
/**
* Vue serialisee de l'etat de la licence pour le frontend.
* Le {@code rawJwt} n'est volontairement JAMAIS expose.
*/
public record LicenseStatusDTO(
boolean enabled,
String status,
String patreonUserId,
String tierId,
String instanceId,
Instant expiresAt,
Instant lastRefreshAttemptAt,
Boolean lastRefreshSucceeded,
boolean betaChannelEnabled
) {
public static LicenseStatusDTO from(boolean enabled, LicenseSnapshot snap) {
return new LicenseStatusDTO(
enabled,
snap.status().name(),
snap.patreonUserId(),
snap.tierId(),
snap.instanceId(),
snap.expiresAt(),
snap.lastRefreshAttemptAt(),
snap.lastRefreshAttemptAt() != null ? snap.lastRefreshSucceeded() : null,
snap.betaChannelEnabled()
);
}
}

View File

@@ -68,3 +68,38 @@ update-check.images=${UPDATE_CHECK_IMAGES:}
update-check.tag=${UPDATE_CHECK_TAG:latest}
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
update-check.watchtower-token=${WATCHTOWER_TOKEN:}
# ============================================================================
# Licensing (canal beta gate par Patreon)
# ============================================================================
# URL du relais OAuth Patreon (Cloudflare Workers). En prod : valeur par defaut.
licensing.relay.base-url=${LICENSING_RELAY_BASE_URL:https://loremind-auth.igmlcreation.fr}
# Cle publique Ed25519 (PEM SPKI) qui verifie les JWT emis par le relais.
# En prod : chargee automatiquement depuis classpath:licensing/jwt-public-key.pem
# (embarquee dans le binaire). Cette propriete sert UNIQUEMENT a la rotation
# de cle ou aux tests : si LICENSING_JWT_PUBLIC_KEY est defini, il prevaut
# sur le fichier embarque.
licensing.jwt.public-key=${LICENSING_JWT_PUBLIC_KEY:}
licensing.jwt.expected-issuer=loremind-auth
licensing.jwt.expected-audience=loremind-instance
# Periode de tolerance apres expiration du JWT pendant laquelle l'instance
# garde l'acces beta meme si le relais est indisponible pour le refresh.
licensing.grace-period-days=14
# Avant J-N de l'expiration, le daemon tente un refresh.
licensing.refresh-before-expiry-days=2
# Identifiant stable de l'instance (UUID genere a la premiere connexion Patreon
# et conserve en base). Utilise dans le state OAuth + dans le JWT.
licensing.instance-id-file=${LICENSING_INSTANCE_ID_FILE:}
# Image beta : si la licence est valide ET le toggle canal beta active,
# UpdateCheckService check ces images en plus du canal stable.
licensing.beta.images=${LICENSING_BETA_IMAGES:igmlcreation/loremind-beta-core,igmlcreation/loremind-beta-brain,igmlcreation/loremind-beta-web}
licensing.beta.tag=${LICENSING_BETA_TAG:latest}
# Chemin de sortie pour le docker config.json partage avec Watchtower.
# Volume Docker `docker-config` monte sur ce chemin dans Core, et sur
# `/shared/docker` dans Watchtower (DOCKER_CONFIG=/shared/docker).
licensing.docker-config-path=${LICENSING_DOCKER_CONFIG_PATH:/shared/docker/config.json}

View File

@@ -0,0 +1,29 @@
# Cle publique JWT du relais OAuth Patreon
Le fichier `jwt-public-key.pem` contient la **cle publique Ed25519** qui sert
a verifier la signature des JWT licence emis par le relais
(`loremind-auth.igmlcreation.fr`).
## Pourquoi ici ?
- C'est une **cle publique** : par nature non-secrete, elle peut etre committee
dans le repo public et embarquee dans le binaire distribue.
- Cela evite a chaque utilisateur final de devoir renseigner manuellement la
cle dans son `.env` au moment de l'installation.
- L'env `LICENSING_JWT_PUBLIC_KEY` peut surcharger cette valeur (utile pour
la rotation de cle sans rebuild ou pour les tests).
## Si le fichier est absent
La feature licensing est **desactivee silencieusement** : `LicenseService.isLicensingEnabled()`
renvoie `false`, et l'UI masque toute la section Patreon.
## Rotation de cle
1. Generer une nouvelle paire dans le relais : `npm run keys:generate`
2. Pousser la nouvelle cle privee : `wrangler secret put JWT_PRIVATE_KEY`
3. Remplacer `jwt-public-key.pem` ici avec la nouvelle cle publique
4. Rebuild + redeployer LoreMind (les anciens JWT seront refuses au prochain
refresh, l'utilisateur sera invite a reconnecter Patreon)
5. Optionnel : pendant la transition, supporter les deux cles en parallele
(pas implemente en MVP, peut etre ajoute si besoin operationnel)

View File

@@ -94,6 +94,19 @@ services:
UPDATE_CHECK_TAG: ${TAG:-latest}
WATCHTOWER_URL: http://watchtower:8080
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
# Licensing : la cle publique JWT est embarquee dans le binaire
# (core/src/main/resources/licensing/jwt-public-key.pem).
# LICENSING_JWT_PUBLIC_KEY est un override optionnel (rotation de cle
# sans rebuild) - non defini par defaut.
LICENSING_JWT_PUBLIC_KEY: ${LICENSING_JWT_PUBLIC_KEY:-}
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
# Chemin du docker config.json partage avec Watchtower
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
volumes:
# Volume partage avec Watchtower : Core ecrit les credentials registry
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
# privees du canal beta. Pas de creds = no-op.
- docker-config:/shared/docker
restart: unless-stopped
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
@@ -169,7 +182,14 @@ services:
profiles: ["autoupdate"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Volume partage avec Core : credentials registry GHCR (canal beta).
# Watchtower lit le config.json depuis DOCKER_CONFIG.
- docker-config:/shared/docker
environment:
# Indique a Watchtower (et au CLI Docker embarque) ou trouver le
# config.json. Active automatiquement l'auth GHCR pour les images
# du canal beta des que Core a ecrit le fichier.
DOCKER_CONFIG: /shared/docker
WATCHTOWER_LABEL_ENABLE: "true"
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_INCLUDE_RESTARTING: "true"
@@ -191,3 +211,6 @@ volumes:
minio-data:
brain-data:
ollama-data:
# Volume partage Core <-> Watchtower : config.json Docker pour
# l'authentification au registry prive GHCR (canal beta Patreon).
docker-config:

View File

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

4
web/package-lock.json generated
View File

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

View File

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

View File

@@ -0,0 +1,94 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, catchError, of } from 'rxjs';
/**
* Reflet de LicenseStatus (enum cote backend).
*/
export type LicenseStatus = 'NONE' | 'VALID' | 'GRACE' | 'EXPIRED' | 'UNVERIFIABLE';
export interface LicenseStatusDTO {
enabled: boolean;
status: LicenseStatus;
patreonUserId: string | null;
tierId: string | null;
instanceId: string | null;
expiresAt: string | null;
lastRefreshAttemptAt: string | null;
lastRefreshSucceeded: boolean | null;
betaChannelEnabled: boolean;
}
/**
* Reflet de UpdateCheckService.BetaStatus.
*/
export interface BetaStatusDTO {
enabled: boolean;
updateAvailable: boolean;
anyUnknown: boolean;
images: Array<{
image: string;
localDigest: string | null;
remoteDigest: string | null;
status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
updateAvailable: boolean;
}>;
checkedAt: string;
disabledReason: string | null;
}
/**
* Service Angular pour la gestion de la licence Patreon.
* Tous les endpoints sont proteges par HTTP Basic (admin).
*/
@Injectable({ providedIn: 'root' })
export class LicenseService {
private readonly apiUrl = '/api/license';
private readonly authOptions = { withCredentials: true };
constructor(private http: HttpClient) {}
getStatus(): Observable<LicenseStatusDTO | null> {
return this.http.get<LicenseStatusDTO>(this.apiUrl, this.authOptions).pipe(
catchError(() => of(null))
);
}
getConnectUrl(): Observable<{ url: string } | null> {
return this.http.get<{ url: string }>(`${this.apiUrl}/connect-url`, this.authOptions).pipe(
catchError(() => of(null))
);
}
install(jwt: string): Observable<LicenseStatusDTO | { error: string }> {
return this.http.post<LicenseStatusDTO>(`${this.apiUrl}/install`, { jwt }, this.authOptions).pipe(
catchError((err) => of({ error: err?.error?.error ?? 'Echec de l\'installation' }))
);
}
disconnect(): Observable<boolean> {
return this.http.delete<void>(this.apiUrl, this.authOptions).pipe(
// Convertit en boolean : true = succes, false = erreur
// (catchError plus bas masque les detail HTTP)
catchError(() => of(false as any))
) as unknown as Observable<boolean>;
}
refresh(): Observable<LicenseStatusDTO | null> {
return this.http.post<LicenseStatusDTO>(`${this.apiUrl}/refresh`, null, this.authOptions).pipe(
catchError(() => of(null))
);
}
setBetaChannel(enabled: boolean): Observable<LicenseStatusDTO | null> {
return this.http.put<LicenseStatusDTO>(`${this.apiUrl}/beta-channel`, { enabled }, this.authOptions).pipe(
catchError(() => of(null))
);
}
checkBeta(): Observable<BetaStatusDTO | null> {
return this.http.get<BetaStatusDTO>('/api/admin/updates/check-beta', this.authOptions).pipe(
catchError(() => of(null))
);
}
}

View File

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

View File

@@ -364,3 +364,103 @@
padding: 0.15rem 0.5rem;
border-radius: 3px;
}
// --- Sous-blocs canaux (stable / beta) ----------------------------------
.channel-block {
margin-top: 16px;
& + & {
margin-top: 28px;
padding-top: 22px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
&:first-child {
margin-top: 0;
}
}
.channel-title {
font-size: 1rem;
margin: 0 0 12px;
color: #c4b8e0;
display: flex;
align-items: center;
gap: 6px;
}
// --- Section Patreon / canal beta ---------------------------------------
.license-info {
list-style: none;
margin: 12px 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
li {
font-size: 0.9rem;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 4px;
strong {
color: #c4b8e0;
margin-right: 6px;
}
}
}
.form-row-inline {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.form-row-actions {
display: flex;
gap: 10px;
margin-top: 12px;
flex-wrap: wrap;
}
.btn-secondary.btn-danger {
border-color: rgba(220, 80, 80, 0.4);
color: #ff7878;
&:hover {
background: rgba(220, 80, 80, 0.12);
border-color: rgba(220, 80, 80, 0.6);
}
}
.beta-status {
margin-top: 16px;
code {
background: rgba(0, 0, 0, 0.3);
padding: 1px 5px;
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.85em;
}
}
label.checkbox {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
cursor: pointer;
}
input[type="checkbox"]:disabled + span {
opacity: 0.5;
cursor: not-allowed;
}
}

View File

@@ -2,11 +2,12 @@ import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X } from 'lucide-angular';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
import { Subscription } from 'rxjs';
import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service';
import { LicenseService, LicenseStatusDTO, BetaStatusDTO } from '../services/license.service';
/**
* Ecran de parametrage du LLM utilise par le Brain.
@@ -37,6 +38,19 @@ export class SettingsComponent implements OnInit {
readonly Trash2 = Trash2;
readonly Plus = Plus;
readonly X = X;
readonly Heart = Heart;
readonly Link2 = Link2;
readonly Unlink = Unlink;
// --- Licence Patreon (canal beta) ---
licenseStatus: LicenseStatusDTO | null = null;
licenseLoading = false;
licenseError = '';
/** Token JWT colle par l'utilisateur apres OAuth. */
licenseJwtInput = '';
/** Etat du canal beta (digests des images privees). */
betaStatus: BetaStatusDTO | null = null;
betaChecking = false;
// --- Pull / delete de modeles Ollama ---
/** Dialog d'ajout de modele ouvert/ferme. */
@@ -105,7 +119,8 @@ export class SettingsComponent implements OnInit {
private settingsService: SettingsService,
private router: Router,
private updatesService: UpdatesService,
public config: ConfigService
public config: ConfigService,
private licenseService: LicenseService
) {}
ngOnInit(): void {
@@ -113,6 +128,117 @@ export class SettingsComponent implements OnInit {
if (this.config.updateCheckEnabled) {
this.checkUpdates();
}
this.loadLicense();
}
// --- Licence Patreon ---------------------------------------------------
loadLicense(): void {
this.licenseLoading = true;
this.licenseService.getStatus().subscribe({
next: (s) => {
this.licenseStatus = s;
this.licenseLoading = false;
if (s?.enabled && (s.status === 'VALID' || s.status === 'GRACE') && s.betaChannelEnabled) {
this.checkBeta();
}
},
error: () => { this.licenseLoading = false; }
});
}
/**
* Ouvre la page OAuth Patreon dans une nouvelle fenetre.
* L'utilisateur copie ensuite le JWT et le colle dans l'input ci-dessous.
*/
connectPatreon(): void {
this.licenseError = '';
this.licenseService.getConnectUrl().subscribe({
next: (r) => {
if (!r?.url) {
this.licenseError = 'Impossible de generer l\'URL de connexion. Verifie ta config.';
return;
}
window.open(r.url, '_blank', 'noopener');
}
});
}
installLicense(): void {
const jwt = this.licenseJwtInput.trim();
if (!jwt) {
this.licenseError = 'Colle d\'abord le token recu apres connexion Patreon.';
return;
}
this.licenseError = '';
this.licenseService.install(jwt).subscribe((res) => {
if ((res as any)?.error) {
this.licenseError = (res as any).error;
return;
}
this.licenseStatus = res as LicenseStatusDTO;
this.licenseJwtInput = '';
this.successMessage = 'Compte Patreon connecte. L\'acces beta est actif.';
if (this.licenseStatus.betaChannelEnabled) {
this.checkBeta();
}
});
}
refreshLicense(): void {
this.licenseLoading = true;
this.licenseService.refresh().subscribe({
next: (s) => {
this.licenseStatus = s;
this.licenseLoading = false;
},
error: () => { this.licenseLoading = false; }
});
}
disconnectPatreon(): void {
if (!confirm('Deconnecter ton compte Patreon ? Tu perdras l\'acces au canal beta.')) return;
this.licenseService.disconnect().subscribe(() => {
this.licenseStatus = null;
this.betaStatus = null;
this.successMessage = 'Compte Patreon deconnecte.';
this.loadLicense();
});
}
toggleBetaChannel(enabled: boolean): void {
this.licenseService.setBetaChannel(enabled).subscribe({
next: (s) => {
if (s) this.licenseStatus = s;
if (enabled) this.checkBeta();
else this.betaStatus = null;
}
});
}
checkBeta(): void {
this.betaChecking = true;
this.licenseService.checkBeta().subscribe({
next: (s) => {
this.betaStatus = s;
this.betaChecking = false;
},
error: () => { this.betaChecking = false; }
});
}
/** Format human-readable des dates renvoyees par le backend. */
formatDate(iso: string | null | undefined): string {
if (!iso) return '';
try { return new Date(iso).toLocaleString(); } catch { return iso; }
}
/** Nombre de jours restants avant expiration JWT (peut etre negatif). */
get daysUntilExpiry(): number | null {
if (!this.licenseStatus?.expiresAt) return null;
const exp = new Date(this.licenseStatus.expiresAt).getTime();
const now = Date.now();
return Math.ceil((exp - now) / (1000 * 60 * 60 * 24));
}
checkUpdates(): void {