Skip to main content

phemios_core/
personal_backup.rs

1//! Explicit, additive portable restoration for the current native principal only.
2use crate::{article_store, federation::Source, network::Client, podcast_store};
3use serde_json::{Value, json};
4const LIMIT: usize = 12 * 1024 * 1024;
5fn fields(value: &Value, allowed: &[&str]) -> Result<(), String> {
6    let object = value
7        .as_object()
8        .ok_or("Sauvegarde des préférences invalide.")?;
9    if object.len() != allowed.len() || object.keys().any(|key| !allowed.contains(&key.as_str())) {
10        return Err("Sauvegarde des préférences invalide.".into());
11    }
12    Ok(())
13}
14fn text(value: &Value, max: usize) -> bool {
15    value.as_str().is_some_and(|s| {
16        !s.trim().is_empty() && s.encode_utf16().count() <= max && !s.chars().any(char::is_control)
17    })
18}
19/// Attribution is bounded metadata, never authority for choosing a destination account.
20pub fn validate(payload: &Value) -> Result<(), String> {
21    fields(payload, &["origin", "podcasts", "articles"])?;
22    let origin = &payload["origin"];
23    fields(origin, &["source", "authority", "accountId", "name"])?;
24    if !matches!(origin["source"].as_str(), Some("local" | "server"))
25        || !text(&origin["authority"], 4096)
26        || !text(&origin["accountId"], 256)
27        || !text(&origin["name"], 256)
28        || (origin["source"] == "local" && origin["authority"] != "phemios-managed-local")
29    {
30        return Err("Sauvegarde des préférences invalide.".into());
31    }
32    fields(&payload["podcasts"], &["feeds", "episodes"])?;
33    podcast_store::validate(&payload["podcasts"])?;
34    article_store::validate(&json!({"articles":payload["articles"]}))?;
35    if serde_json::to_vec(payload)
36        .map_err(|_| "Sauvegarde des préférences invalide.")?
37        .len()
38        > LIMIT
39    {
40        return Err("Sauvegarde des préférences invalide.".into());
41    }
42    Ok(())
43}
44/// Both stores must be readable; never export an empty substitute for unreadable private data.
45pub fn read(
46    podcasts: &podcast_store::Store,
47    articles: &article_store::Store,
48    source: Source,
49    client: &Client,
50) -> Result<Value, String> {
51    let account = client.account();
52    let id = account["account"]["id"]
53        .as_str()
54        .ok_or("Compte privé invalide.")?;
55    let name = account["account"]["username"].as_str().unwrap_or(id);
56    let authority = if source == Source::Local {
57        "phemios-managed-local"
58    } else {
59        account["server"].as_str().ok_or("Compte privé invalide.")?
60    };
61    let podcast = podcasts.read(source, client)?;
62    let article = articles.read(source, client)?;
63    let value = json!({"origin":{"source":source.name(),"authority":authority,"accountId":id,"name":name},"podcasts":podcast,"articles":article["articles"]});
64    validate(&value)?;
65    Ok(value)
66}
67/// Validate both retained stores and combined quotas before publishing anything. Imports add missing
68/// identities only; existing subscriptions/editions/evidence win. Files publish independently, so
69/// an IO failure after the first publication may leave safe additions; retry is idempotent.
70pub fn restore(
71    podcasts: &podcast_store::Store,
72    articles: &article_store::Store,
73    source: Source,
74    client: &Client,
75    payload: &Value,
76) -> Result<(), String> {
77    validate(payload)?;
78    let podcast = podcasts.prepare_restore(source, client, &payload["podcasts"])?;
79    let article =
80        articles.prepare_restore(source, client, &json!({"articles":payload["articles"]}))?;
81    podcasts.commit_restore(source, client, podcast)?;
82    articles.commit_restore(source,client,article).map_err(|_| "Restauration interrompue. Les données existantes sont conservées ; réessayez l’import pour terminer.".to_owned())
83}
84#[cfg(test)]
85#[path = "personal_backup_tests.rs"]
86mod tests;