1use 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;
10pub struct Store {
12 document: private_documents::Store,
13}
14impl Store {
15 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 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 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 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 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}
150fn 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;