v3 : publication robuste et visible dans trustinWallet
- Clé de publication ed25519 générée au démarrage dans le volume ; sa partie publique et l'état de la file d'envoi s'affichent dans /acces. - Publication initiale à l'instanciation d'un document seedé publiable. - Reprise au démarrage : chaque document publié repasse une fois dans la file ; la date publiée est celle du dernier changement, un document inchangé ne fait pas de commit (testé contre un dépôt nu). - Le travailleur fait un premier passage sans attendre ; journaux sans codes couleur hors terminal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> {
|
||||
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<String> {
|
||||
let at: Option<String> = 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<Vec<(String, String)>> {
|
||||
let titles = util::parse_lang(&st.doc.title);
|
||||
@@ -191,7 +258,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(Stri
|
||||
.collect();
|
||||
let refs: Vec<&docs::ProposalRow> = 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 {
|
||||
|
||||
Reference in New Issue
Block a user