diff --git a/crates/ld-server/src/access.rs b/crates/ld-server/src/access.rs index d24559c..c3d7aa1 100644 --- a/crates/ld-server/src/access.rs +++ b/crates/ld-server/src/access.rs @@ -41,7 +41,12 @@ pub async fn overview(State(state): State, User(user): User) -> AppRes .bind(util::now()) .fetch_all(&state.db) .await?; + let publication = json!({ + "remote": state.cfg.publish.as_ref().map(|p| p.remote.clone()), + "publicKey": crate::publish::public_key(&state), + }); Ok(Json(json!({ + "publication": publication, "groups": groups.iter().map(|(slug, name, policy, hidden)| json!({ "slug": slug, "name": name, "policy": policy, "hidden": hidden, "members": members.iter().filter(|m| &m.group_slug == slug) diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index 1cb9b39..a83cafe 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -160,6 +160,7 @@ pub async fn run() -> anyhow::Result<()> { tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,sqlx=warn,tower_http=warn".into()), ) + .with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout())) .init(); let cfg = Config::from_env(); tokio::fs::create_dir_all(&cfg.data_dir).await?; @@ -169,6 +170,10 @@ pub async fn run() -> anyhow::Result<()> { seed::ensure(&state).await?; auth::bootstrap(&state).await?; + if let Err(e) = publish::ensure_key(&state).await { + tracing::warn!(error = %e, "publication key"); + } + publish::resync(&state).await?; tokio::spawn(wot::refresher(state.clone())); tokio::spawn(publish::worker(state.clone())); tokio::spawn(adoption::sweeper(state.clone())); diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index f02f9bb..6acbaf6 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -789,7 +789,14 @@ mod tests { use super::*; fn unit(id: &str, position: f64) -> UnitRow { - UnitRow { id: id.into(), code: id.into(), section: None, position, inertia: "standard".into(), in_force_id: None } + UnitRow { + id: id.into(), + code: id.into(), + section: None, + position, + inertia: "standard".into(), + in_force_id: None, + } } #[test] diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index 17b7c5c..957410f 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -19,18 +19,46 @@ use tokio::process::Command; const IDLE: Duration = Duration::from_secs(60); const MAX_BACKOFF_SECS: i64 = 6 * 3600; +/// The deploy key of the server: generated on first start, never leaves the +/// data volume. Its public half is shown to access administrators, who add it +/// as a deploy key (write) on the texts repository. +pub async fn ensure_key(state: &AppState) -> anyhow::Result<()> { + let Some(publish) = &state.cfg.publish else { return Ok(()) }; + if publish.key.exists() { + return Ok(()); + } + if let Some(dir) = publish.key.parent() { + tokio::fs::create_dir_all(dir).await?; + } + let out = Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", "", "-C", "libredecision-publication", "-f"]) + .arg(&publish.key) + .output() + .await?; + anyhow::ensure!(out.status.success(), "ssh-keygen: {}", String::from_utf8_lossy(&out.stderr).trim()); + tracing::info!(key = %publish.key.display(), "publication key generated"); + Ok(()) +} + +pub fn public_key(state: &AppState) -> Option { + let publish = state.cfg.publish.as_ref()?; + let mut path = publish.key.clone().into_os_string(); + path.push(".pub"); + std::fs::read_to_string(path).ok().map(|k| k.trim().to_owned()) +} + pub async fn worker(state: AppState) { loop { + // A pass first: what was queued before the start goes out without waiting. + if state.cfg.publish.is_some() + && let Err(e) = pass(&state).await + { + tracing::warn!(error = %e, "publication pass"); + } tokio::select! { () = state.publish_wake.notified() => {} () = tokio::time::sleep(IDLE) => {} } - if state.cfg.publish.is_none() { - continue; - } - if let Err(e) = pass(&state).await { - tracing::warn!(error = %e, "publication pass"); - } } } @@ -41,7 +69,8 @@ struct Due { attempts: i64, } -async fn pass(state: &AppState) -> anyhow::Result<()> { +/// One publication pass, now (tests, and the worker). +pub async fn pass(state: &AppState) -> anyhow::Result<()> { let due = sqlx::query_as::<_, Due>( "SELECT id, doc_id, attempts FROM outbox WHERE done_at IS NULL AND next_at <= ? ORDER BY id", ) @@ -177,6 +206,44 @@ vigueur est republié ici. Les applications clientes le lisent pour se mettre à - `document.json` : le même contenu, structuré, avec l'état du vote de chaque article.\n\n\ Ne pas modifier à la main : le prochain passage de libreDecision réécrit ces fichiers.\n"; +/// The date of the last change to what is published (an entry into force, a +/// typo fix, a translation) — never "now", so that publishing an unchanged +/// document changes nothing and commits nothing. +async fn last_change(state: &AppState, doc_id: &str) -> anyhow::Result { + let at: Option = sqlx::query_scalar( + "SELECT MAX(d) FROM ( + SELECT MAX(at) AS d FROM adoptions WHERE doc_id = ?1 + UNION ALL SELECT MAX(p.updated_at) FROM proposals p JOIN units u ON u.in_force_id = p.id WHERE u.doc_id = ?1 + UNION ALL SELECT MAX(t.updated_at) FROM translations t JOIN units u ON u.in_force_id = t.proposal_id WHERE u.doc_id = ?1 + UNION ALL SELECT created_at FROM documents WHERE id = ?1)", + ) + .bind(doc_id) + .fetch_one(&state.db) + .await?; + Ok(at.unwrap_or_else(util::now)) +} + +/// On start, every published document is queued once more: a change that +/// entered into force just before a restart, or a new rendering format, reaches +/// the repository. Unchanged documents produce no commit. +pub async fn resync(state: &AppState) -> anyhow::Result<()> { + if state.cfg.publish.is_none() { + return Ok(()); + } + let now = util::now(); + sqlx::query( + "INSERT INTO outbox (doc_id, reason, created_at, next_at) + SELECT d.id, 'reprise au démarrage', ?1, ?1 FROM documents d + WHERE d.publish_path IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM outbox o WHERE o.doc_id = d.id AND o.done_at IS NULL)", + ) + .bind(&now) + .execute(&state.db) + .await?; + state.publish_wake.notify_one(); + Ok(()) +} + /// Markdown per language + JSON. Only the units in force, in order. pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result> { let titles = util::parse_lang(&st.doc.title); @@ -191,7 +258,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result = in_force.iter().map(|(_, p)| *p).collect(); let tr = docs::translations_for(state, &refs).await.map_err(|e| anyhow::anyhow!("{e}"))?; - let updated = util::now(); + let updated = last_change(state, &st.doc.id).await?; let source_url = format!("{}/g/{}/docs/{}", state.cfg.public_url, st.group.slug, st.doc.slug); let mut files = Vec::new(); for lang in util::LANGS { diff --git a/crates/ld-server/src/seed.rs b/crates/ld-server/src/seed.rs index 5f333c1..c7e1b32 100644 --- a/crates/ld-server/src/seed.rs +++ b/crates/ld-server/src/seed.rs @@ -145,11 +145,11 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a .bind(&doc_id) .bind(&unit.code) .bind(&unit.section) - .bind(unit.position) - .bind(&unit.inertia) - .bind(&now) - .execute(&mut *tx) - .await?; + .bind(unit.position) + .bind(&unit.inertia) + .bind(&now) + .execute(&mut *tx) + .await?; for v in &unit.versions { let pid = util::id(); let (title, body) = (pick(&v.title), pick(&v.body)); @@ -204,6 +204,16 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a } } } + if inst.publish_path.is_some() { + sqlx::query( + "INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, 'publication initiale', ?, ?)", + ) + .bind(&doc_id) + .bind(&now) + .bind(&now) + .execute(&mut *tx) + .await?; + } tx.commit().await?; Ok(()) } diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index c43e637..5946bdf 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -151,7 +151,11 @@ async fn adoption_in_the_test_group_and_publication_queued_only_where_published( assert_eq!(e3["inForce"]["id"], p.as_str()); assert!(e3["inForce"]["enteredAt"].is_string()); // The test group publishes nothing. - let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox").fetch_one(&state.db).await.expect("count"); + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE doc_id = ?") + .bind(&id) + .fetch_one(&state.db) + .await + .expect("count"); assert_eq!(n, 0); } @@ -386,3 +390,65 @@ async fn feedback_reaches_administrators() { let (_, v) = admin.call("GET", "/api/access/feedback", None).await; assert_eq!(v["feedback"][0]["body"], "Le bouton est trop discret."); } + +#[tokio::test] +async fn publication_writes_the_texts_in_force_to_the_repository() { + use std::process::Command; + let dir = std::env::temp_dir().join(format!("ld-pub-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir"); + let remote = dir.join("textes.git"); + assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success()); + let mut cfg = Config::for_tests(); + cfg.data_dir = dir.join("data"); + cfg.publish = Some(ld_server::config::Publish { + remote: remote.display().to_string(), + branch: "main".into(), + key: dir.join("data/keys/publish_ed25519"), + author_name: "libreDecision".into(), + author_email: "decision@example.org".into(), + }); + let db = open_db("sqlite::memory:").await.expect("db"); + let state = AppState::new(cfg, db).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + ld_server::publish::pass(&state).await.expect("pass"); + let show = |path: &str| { + let out = Command::new("git") + .arg("-C") + .arg(&remote) + .args(["show", &format!("main:{path}")]) + .output() + .expect("git show"); + String::from_utf8(out.stdout).expect("utf8") + }; + let fr = show("monnaie-libre/acte-engagement-certification/fr.md"); + assert!(fr.contains("# Acte d'engagement Certification"), "{fr}"); + assert!(fr.contains("## Préambule") && fr.contains("## Unicité du compte") && fr.contains("## Réciprocité")); + assert!(!fr.contains("Connaissance suffisante"), "only the texts in force are published"); + assert!(show("monnaie-libre/acte-engagement-certification/en.md").contains("traduction: editoriale")); + let json: Value = + serde_json::from_str(&show("monnaie-libre/acte-engagement-certification/document.json")).expect("json"); + assert_eq!(json["units"].as_array().expect("units").len(), 3); + assert_eq!(json["units"][0]["status"], "seed-minimal"); + assert!(show("README.md").contains("libreDecision")); + let done: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL") + .fetch_one(&state.db) + .await + .expect("count"); + assert_eq!(done, 0); + // A restart queues the document again; nothing changed, so nothing is committed. + let head = || { + let out = Command::new("git").arg("-C").arg(&remote).args(["rev-parse", "main"]).output().expect("rev-parse"); + String::from_utf8(out.stdout).expect("utf8") + }; + let before = head(); + ld_server::publish::resync(&state).await.expect("resync"); + ld_server::publish::pass(&state).await.expect("pass"); + assert_eq!(head(), before, "an unchanged document produces no commit"); + let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL") + .fetch_one(&state.db) + .await + .expect("count"); + assert_eq!(pending, 0); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 7bb4050..b4c1837 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -286,6 +286,10 @@ export const en: Messages = { 'access.feedback.none': 'No feedback.', 'access.feedback.seen': 'Seen', 'access.feedback.done': 'Handled', + 'access.publication': "Publishing the texts", + 'access.publication.remote': "Repository: {remote}", + 'access.publication.key': "The server's deploy key. Add it with write access on this repository (Settings › Repository › Deploy keys): until then, publications wait.", + 'access.publication.none': "Publishing is not configured on this server.", 'access.denied': 'Access management is for trustinWallet administrators only.', 'soon.title': 'Coming soon', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 53177f1..70ddcb1 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -286,6 +286,10 @@ export const es: Messages = { 'access.feedback.none': 'Ningún comentario.', 'access.feedback.seen': 'Visto', 'access.feedback.done': 'Atendido', + 'access.publication': "Publicación de los textos", + 'access.publication.remote': "Repositorio: {remote}", + 'access.publication.key': "Clave de despliegue del servidor. Añádela con permiso de escritura en este repositorio (Configuración › Repositorio › Claves de despliegue): sin ella, las publicaciones esperan.", + 'access.publication.none': "La publicación no está configurada en este servidor.", 'access.denied': 'La gestión de accesos está reservada a los administradores de trustinWallet.', 'soon.title': 'Próximamente', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 2064f04..c587217 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -289,6 +289,10 @@ export const fr = { 'access.feedback.none': 'Aucun retour.', 'access.feedback.seen': 'Vu', 'access.feedback.done': 'Traité', + 'access.publication': "Publication des textes", + 'access.publication.remote': "Dépôt : {remote}", + 'access.publication.key': "Clé de déploiement du serveur. Ajoute-la avec le droit d'écriture sur ce dépôt (Paramètres › Dépôt › Clés de déploiement) : sans elle, les publications attendent.", + 'access.publication.none': "La publication n'est pas configurée sur ce serveur.", 'access.denied': 'La gestion des accès est réservée aux administrateurs de trustinWallet.', 'soon.title': 'À venir', diff --git a/web/src/routes/acces/+page.svelte b/web/src/routes/acces/+page.svelte index c3b541f..b8cc03c 100644 --- a/web/src/routes/acces/+page.svelte +++ b/web/src/routes/acces/+page.svelte @@ -8,6 +8,7 @@ import { toasts } from '$lib/toast.svelte'; interface Overview { + publication: { remote: string | null; publicKey: string | null }; groups: { slug: string; name: string; policy: string; hidden: boolean; members: { identityId: string; grantedAt: string }[] }[]; identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null }[]; invitations: { id: string; label: string; admin: boolean; groups: string[]; expiresAt: string }[]; @@ -153,6 +154,21 @@ +
+

{t('access.publication')}

+
+ {#if data.value.publication.remote} +

{t('access.publication.remote', { remote: data.value.publication.remote })}

+ {#if data.value.publication.publicKey} +

{t('access.publication.key')}

+ {data.value.publication.publicKey} + {/if} + {:else} +

{t('access.publication.none')}

+ {/if} +
+
+

{t('access.feedback')}

{#each fb.value?.feedback ?? [] as f (f.id)} @@ -258,6 +274,13 @@ color: var(--ochre); font-weight: 700; } + .key { + word-break: break-all; + font-size: 0.8rem; + background: var(--surface-2); + padding: 0.6rem 0.8rem; + border-radius: var(--r-input); + } .fb .body { white-space: pre-wrap; }