Skip to main content

phemios_core/
podcast_store.rs

1//! Persistent private podcast documents; callers supply only a trusted native Client.
2use crate::{federation::Source, network::Client, private_documents};
3use serde_json::{Value, json};
4#[cfg(test)]
5use std::fs;
6use std::path::PathBuf;
7const PART_BYTES: usize = 4 * 1024 * 1024;
8#[cfg(test)]
9const MAX_BYTES: usize = 2 * PART_BYTES + 4096;
10/// Fixed application-owned folder, serialized by the native bridge.
11pub struct Store {
12    document: private_documents::Store,
13}
14impl Store {
15    /// Construction never reads, creates or repairs a retained object.
16    pub fn new(root: PathBuf) -> Self {
17        Self {
18            document: private_documents::Store::new(root, private_documents::Kind::Podcasts),
19        }
20    }
21    fn owner(source: Source, client: &Client) -> Result<Value, String> {
22        private_documents::owner(source, client)
23    }
24    #[cfg(test)]
25    fn path(&self, owner: &Value) -> PathBuf {
26        self.document.path(owner)
27    }
28    fn document(&self, owner: &Value) -> Result<Value, String> {
29        let value = self
30            .document
31            .read(owner)?
32            .unwrap_or_else(|| json!({"feeds":[],"episodes":[]}));
33        validate(&value)?;
34        Ok(value)
35    }
36    /// Prepare an additive explicit restore, retaining current subscriptions/evidence and document extensions.
37    pub(crate) fn prepare_restore(
38        &self,
39        source: Source,
40        client: &Client,
41        imported: &Value,
42    ) -> Result<Value, String> {
43        writable(client)?;
44        validate(imported)?;
45        let owner = Self::owner(source, client)?;
46        let mut value = self.document(&owner)?;
47        for field in ["feeds", "episodes"] {
48            let incoming = imported[field]
49                .as_array()
50                .ok_or("Données podcasts illisibles.")?;
51            let held = value[field]
52                .as_array_mut()
53                .ok_or("Données podcasts illisibles.")?;
54            for row in incoming {
55                let exists = held.iter().any(|existing| {
56                    if field == "feeds" {
57                        existing["url"] == row["url"]
58                    } else {
59                        existing["feed"] == row["feed"] && existing["id"] == row["id"]
60                    }
61                });
62                if !exists {
63                    held.push(row.clone());
64                }
65            }
66        }
67        validate(&value)?;
68        self.document.preflight(&owner, &value)?;
69        Ok(value)
70    }
71    /// Publish a previously preflighted restore while the bridge retains this store lock.
72    pub(crate) fn commit_restore(
73        &self,
74        source: Source,
75        client: &Client,
76        prepared: Value,
77    ) -> Result<(), String> {
78        writable(client)?;
79        validate(&prepared)?;
80        self.document.write(&Self::owner(source, client)?, prepared)
81    }
82    /// Read only this authenticated source/authority/account; legacy device stores are unrelated.
83    pub fn read(&self, source: Source, client: &Client) -> Result<Value, String> {
84        let value = self.document(&Self::owner(source, client)?)?;
85        Ok(json!({"feeds":value["feeds"],"episodes":value["episodes"]}))
86    }
87    /// Apply one narrow mutation without trusting a webview owner, pathname or full replacement.
88    pub fn update(
89        &self,
90        source: Source,
91        client: &Client,
92        action: &str,
93        payload: &Value,
94    ) -> Result<Value, String> {
95        writable(client)?;
96        let owner = Self::owner(source, client)?;
97        let mut value = self.document(&owner)?;
98        match action {
99            "save" => {
100                validate_feed(payload)?;
101                let rows = value["feeds"]
102                    .as_array_mut()
103                    .ok_or("Données podcasts illisibles.")?;
104                if let Some(row) = rows.iter_mut().find(|row| row["url"] == payload["url"]) {
105                    *row = payload.clone();
106                } else {
107                    rows.push(payload.clone());
108                }
109            }
110            "remove" => {
111                let url = payload.as_str().ok_or("Adresse podcast invalide.")?;
112                crate::podcast::feed_address(url)?;
113                value["feeds"]
114                    .as_array_mut()
115                    .ok_or("Données podcasts illisibles.")?
116                    .retain(|row| row["url"] != url);
117            }
118            "mark" => {
119                validate_marker(payload)?;
120                let rows = value["episodes"]
121                    .as_array_mut()
122                    .ok_or("Données podcasts illisibles.")?;
123                if !rows.iter().any(|row| row == payload) {
124                    rows.push(payload.clone());
125                }
126            }
127            _ => return Err("Action podcast invalide.".into()),
128        }
129        validate(&value)?;
130        self.document.write(&owner, value)?;
131        self.read(source, client)
132    }
133}
134fn writable(client: &Client) -> Result<(), String> {
135    if matches!(
136        client.account()["account"]["role"].as_str(),
137        Some("user" | "admin")
138    ) {
139        Ok(())
140    } else {
141        Err("Ce compte ne peut pas modifier ses podcasts.".into())
142    }
143}
144
145fn text(value: &Value, max: usize, empty: bool) -> bool {
146    value.as_str().is_some_and(|text| {
147        text.encode_utf16().count() <= max && (empty || !text.trim().is_empty())
148    })
149}
150// The RSS parser emits Date.toISOString(), including signed six-digit years.
151fn canonical_date(value: &Value) -> bool {
152    if value.is_null() {
153        return true;
154    }
155    let Some(date) = value.as_str().filter(|s| s.is_ascii()) else {
156        return false;
157    };
158    let (year, offset) = match date.len() {
159        24 if date.as_bytes()[..4].iter().all(u8::is_ascii_digit) => {
160            (date.get(..4).and_then(|s| s.parse::<i32>().ok()), 4)
161        }
162        27 if matches!(date.as_bytes()[0], b'+' | b'-')
163            && date.as_bytes()[1..7].iter().all(u8::is_ascii_digit) =>
164        {
165            let year = date
166                .get(1..7)
167                .and_then(|s| s.parse::<i32>().ok())
168                .map(|n| if date.starts_with('-') { -n } else { n });
169            if year.is_some_and(|n| (0..=9999).contains(&n)) {
170                return false;
171            }
172            (year, 7)
173        }
174        _ => return false,
175    };
176    let Some(year) = year else {
177        return false;
178    };
179    let tail = &date[offset..];
180    if !tail.as_bytes().iter().enumerate().all(|(i, b)| match i {
181        0 | 3 => *b == b'-',
182        6 => *b == b'T',
183        9 | 12 => *b == b':',
184        15 => *b == b'.',
185        19 => *b == b'Z',
186        _ => b.is_ascii_digit(),
187    }) {
188        return false;
189    }
190    let number = |start, end| tail.get(start..end).and_then(|s| s.parse::<i32>().ok());
191    let (Some(month), Some(day), Some(hour), Some(minute), Some(second), Some(ms)) = (
192        number(1, 3),
193        number(4, 6),
194        number(7, 9),
195        number(10, 12),
196        number(13, 15),
197        number(16, 19),
198    ) else {
199        return false;
200    };
201    let leap = year.rem_euclid(4) == 0 && (year.rem_euclid(100) != 0 || year.rem_euclid(400) == 0);
202    let days = match month {
203        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
204        4 | 6 | 9 | 11 => 30,
205        2 => {
206            if leap {
207                29
208            } else {
209                28
210            }
211        }
212        _ => return false,
213    };
214    if !(1..=days).contains(&day) || hour > 23 || minute > 59 || second > 59 {
215        return false;
216    }
217    let stamp = (year, month, day, hour, minute, second, ms);
218    ((-271_821, 4, 20, 0, 0, 0, 0)..=(275_760, 9, 13, 0, 0, 0, 0)).contains(&stamp)
219}
220fn validate_marker(value: &Value) -> Result<(), String> {
221    if !value.is_object() || !text(&value["id"], 4096, false) {
222        return Err("Épisodes écoutés illisibles.".into());
223    }
224    crate::podcast::feed_address(value["feed"].as_str().ok_or("Adresse podcast invalide.")?)?;
225    Ok(())
226}
227fn validate_feed(value: &Value) -> Result<(), String> {
228    crate::podcast::feed_address(value["url"].as_str().ok_or("Adresse podcast invalide.")?)?;
229    if !text(&value["title"], 512, false) || !text(&value["author"], 512, true) {
230        return Err("Abonnements podcasts illisibles.".into());
231    }
232    let episodes = value["episodes"]
233        .as_array()
234        .filter(|rows| rows.len() <= 200)
235        .ok_or("Abonnements podcasts illisibles.")?;
236    let mut ids = std::collections::BTreeSet::new();
237    for row in episodes {
238        if !text(&row["id"], 2048, false)
239            || !text(&row["title"], 512, false)
240            || !text(&row["summary"], 4096, true)
241            || !row.get("date").is_some_and(canonical_date)
242            || !row.get("duration").is_some_and(|value| {
243                value.is_null()
244                    || value
245                        .as_f64()
246                        .is_some_and(|n| n.is_finite() && n > 0. && n <= 86400.)
247            })
248            || !ids.insert(
249                row["id"]
250                    .as_str()
251                    .ok_or("Abonnements podcasts illisibles.")?,
252            )
253        {
254            return Err("Abonnements podcasts illisibles.".into());
255        }
256        crate::podcast::feed_address(row["url"].as_str().ok_or("Adresse podcast invalide.")?)?;
257    }
258    Ok(())
259}
260pub(crate) fn validate(value: &Value) -> Result<(), String> {
261    let feeds = value["feeds"]
262        .as_array()
263        .filter(|rows| rows.len() <= 100)
264        .ok_or("Abonnements podcasts illisibles.")?;
265    let episodes = value["episodes"]
266        .as_array()
267        .filter(|rows| rows.len() <= 4096)
268        .ok_or("Épisodes écoutés illisibles.")?;
269    if serde_json::to_vec(feeds)
270        .map_err(|_| "Abonnements podcasts illisibles.")?
271        .len()
272        > PART_BYTES
273        || serde_json::to_vec(episodes)
274            .map_err(|_| "Épisodes écoutés illisibles.")?
275            .len()
276            > PART_BYTES
277    {
278        return Err("Limite de stockage des podcasts atteinte.".into());
279    }
280    let mut urls = std::collections::BTreeSet::new();
281    for feed in feeds {
282        validate_feed(feed)?;
283        if !urls.insert(feed["url"].as_str().ok_or("Adresse podcast invalide.")?) {
284            return Err("Abonnements podcasts illisibles.".into());
285        }
286    }
287    let mut markers = std::collections::BTreeSet::new();
288    for marker in episodes {
289        validate_marker(marker)?;
290        if !markers.insert(marker.to_string()) {
291            return Err("Épisodes écoutés illisibles.".into());
292        }
293    }
294    Ok(())
295}
296#[cfg(test)]
297#[path = "podcast_store_tests.rs"]
298mod tests;