Skip to main content

phemios_core/
article_store.rs

1//! Bounded owner-private saved article metadata/text; public fetch/export stays independent.
2use crate::{federation::Source, network::Client, private_documents};
3use serde_json::{Value, json};
4use std::path::PathBuf;
5/// Fixed native-owned folder; callers supply only authenticated Client identity.
6pub struct Store {
7    document: private_documents::Store,
8}
9impl Store {
10    /// Construct without creating or repairing a retained object.
11    pub fn new(root: PathBuf) -> Self {
12        Self {
13            document: private_documents::Store::new(root, private_documents::Kind::Articles),
14        }
15    }
16    fn document(&self, owner: &Value) -> Result<Value, String> {
17        let value = self
18            .document
19            .read(owner)?
20            .unwrap_or_else(|| json!({"articles":[]}));
21        validate(&value)?;
22        Ok(value)
23    }
24    /// Prepare an additive explicit restore without replacing current editions or document extensions.
25    pub(crate) fn prepare_restore(
26        &self,
27        source: Source,
28        client: &Client,
29        imported: &Value,
30    ) -> Result<Value, String> {
31        writable(client)?;
32        validate(imported)?;
33        let owner = private_documents::owner(source, client)?;
34        let mut value = self.document(&owner)?;
35        let incoming = imported["articles"]
36            .as_array()
37            .ok_or("Articles enregistrés illisibles.")?;
38        let held = value["articles"]
39            .as_array_mut()
40            .ok_or("Articles enregistrés illisibles.")?;
41        for row in incoming {
42            if !held.iter().any(|existing| {
43                existing["language"] == row["language"] && existing["pageId"] == row["pageId"]
44            }) {
45                held.push(row.clone());
46            }
47        }
48        validate(&value)?;
49        self.document.preflight(&owner, &value)?;
50        Ok(value)
51    }
52    /// Publish a preflighted restore while the bridge retains this store lock.
53    pub(crate) fn commit_restore(
54        &self,
55        source: Source,
56        client: &Client,
57        prepared: Value,
58    ) -> Result<(), String> {
59        writable(client)?;
60        validate(&prepared)?;
61        self.document
62            .write(&private_documents::owner(source, client)?, prepared)
63    }
64    /// Read this source/authority/account's articles, without inspecting legacy device storage.
65    pub fn read(&self, source: Source, client: &Client) -> Result<Value, String> {
66        let value = self.document(&private_documents::owner(source, client)?)?;
67        Ok(json!({"version":1,"articles":value["articles"]}))
68    }
69    /// Save/remove one identity, never a caller-selected owner, path or complete replacement.
70    pub fn update(
71        &self,
72        source: Source,
73        client: &Client,
74        action: &str,
75        payload: &Value,
76    ) -> Result<Value, String> {
77        writable(client)?;
78        identity(payload)?;
79        let owner = private_documents::owner(source, client)?;
80        let mut value = self.document(&owner)?;
81        let rows = value["articles"]
82            .as_array_mut()
83            .ok_or("Articles enregistrés illisibles.")?;
84        let same = |row: &Value| {
85            row["language"] == payload["language"] && row["pageId"] == payload["pageId"]
86        };
87        match action {
88            "save" => {
89                article(payload)?;
90                if let Some(row) = rows.iter_mut().find(|row| same(row)) {
91                    *row = payload.clone();
92                } else {
93                    rows.push(payload.clone());
94                }
95            }
96            "remove" => rows.retain(|row| !same(row)),
97            _ => return Err("Action article invalide.".into()),
98        }
99        validate(&value)?;
100        self.document.write(&owner, value)?;
101        self.read(source, client)
102    }
103}
104fn writable(client: &Client) -> Result<(), String> {
105    if matches!(
106        client.account()["account"]["role"].as_str(),
107        Some("user" | "admin")
108    ) {
109        Ok(())
110    } else {
111        Err("Ce compte ne peut pas modifier ses articles.".into())
112    }
113}
114
115fn identity(value: &Value) -> Result<(), String> {
116    if !matches!(value["language"].as_str(), Some("fr" | "en")) || !positive(&value["pageId"]) {
117        return Err("Article enregistré invalide.".into());
118    }
119    Ok(())
120}
121fn positive(value: &Value) -> bool {
122    value
123        .as_u64()
124        .is_some_and(|n| n > 0 && n <= 9_007_199_254_740_991)
125}
126fn article(value: &Value) -> Result<(), String> {
127    identity(value)?;
128    if let Some(links) = value.get("externalLinks") {
129        crate::encyclopedia::resources::validate(links)?;
130    }
131    let title = value["title"].as_str().is_some_and(|s| {
132        !s.trim().is_empty() && s.encode_utf16().count() <= 2048 && !s.chars().any(char::is_control)
133    });
134    let text = value["text"].as_str().is_some_and(|s| {
135        !s.trim().is_empty()
136            && s.len() <= 512 * 1024
137            && !s
138                .chars()
139                .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
140    });
141    if !positive(&value["revision"])
142        || !value["retrievedAt"]
143            .as_u64()
144            .is_some_and(|n| n > 0 && n <= 8_640_000_000_000_000)
145        || !title
146        || !text
147    {
148        return Err("Article enregistré invalide.".into());
149    }
150    Ok(())
151}
152pub(crate) fn validate(value: &Value) -> Result<(), String> {
153    let rows = value["articles"]
154        .as_array()
155        .filter(|rows| rows.len() <= 40)
156        .ok_or("Articles enregistrés illisibles.")?;
157    if serde_json::to_vec(rows)
158        .map_err(|_| "Articles enregistrés illisibles.")?
159        .len()
160        > 3 * 1024 * 1024
161    {
162        return Err("Limite de stockage des articles atteinte.".into());
163    }
164    let mut ids = std::collections::BTreeSet::new();
165    for row in rows {
166        article(row)?;
167        if !ids.insert((row["language"].as_str(), row["pageId"].as_u64())) {
168            return Err("Articles enregistrés illisibles.".into());
169        }
170    }
171    Ok(())
172}
173#[cfg(test)]
174#[path = "article_store_tests.rs"]
175mod tests;