Skip to main content

phemios_core/
network_parental.rs

1//! Account-bound parental status admission; no policy is inferred from public cached data.
2use super::{Client, Value};
3
4impl Client {
5    /// Old servers expose no parental marker. New servers must return this owner's valid status.
6    pub fn parental_status(&self) -> Result<Option<Value>, String> {
7        self.parental_status_admitted(|| Ok(()))
8    }
9    /// Policy lookups retire busy retries with their captured account admission.
10    pub fn parental_status_admitted(
11        &self,
12        current: impl Fn() -> Result<(), String>,
13    ) -> Result<Option<Value>, String> {
14        current()?;
15        if self.account["role"] == "admin" || !self.account["parental_enabled"].is_boolean() {
16            return Ok(None);
17        }
18        let value = self.request_admitted("GET", "/api/me/v1/parental", &[], None, current)?;
19        validate(&value, self.account["id"].as_str().unwrap_or_default())?;
20        Ok(Some(value))
21    }
22    /// External media require fresh named authority before reusing an administrator or legacy exemption.
23    /// Capable non-administrators already authenticate through their live owner-bound policy read.
24    pub fn external_parental_status_admitted(
25        &self,
26        current: impl Fn() -> Result<(), String>,
27    ) -> Result<Option<Value>, String> {
28        current()?;
29        if self.account["role"] != "admin" && self.account["parental_enabled"].is_boolean() {
30            return self.parental_status_admitted(current);
31        }
32        let owner = self.account["id"]
33            .as_str()
34            .filter(|owner| !owner.is_empty())
35            .ok_or("Session Aède invalide. Reconnectez-vous.")?;
36        let role = self.account["role"]
37            .as_str()
38            .filter(|role| matches!(*role, "admin" | "user" | "auditor"))
39            .ok_or("Session Aède invalide. Reconnectez-vous.")?;
40        let live = self.request_admitted("GET", "/api/auth/v1/session", &[], None, &current)?;
41        if live["id"] != owner || live["enabled"] != true || live["role"] != role {
42            return Err("Session Aède remplacée, désactivée ou invalide. Reconnectez-vous.".into());
43        }
44        if live
45            .get("parental_enabled")
46            .is_some_and(|marker| !marker.is_boolean())
47            || (self.account["parental_enabled"].is_boolean()
48                && !live["parental_enabled"].is_boolean())
49        {
50            return Err("État du contrôle parental invalide.".into());
51        }
52        if role == "admin" || !live["parental_enabled"].is_boolean() {
53            return Ok(None);
54        }
55        let value = self.request_admitted("GET", "/api/me/v1/parental", &[], None, current)?;
56        validate(&value, owner)?;
57        Ok(Some(value))
58    }
59    /// Recheck the live policy before serving a retained projection to a supervised account.
60    pub fn admit_snapshot(&self, snapshot: &Value) -> Result<(), String> {
61        self.admit_snapshot_admitted(snapshot, || Ok(()))
62    }
63    /// Retained projection checks share the caller's cancellable network admission.
64    pub fn admit_snapshot_admitted(
65        &self,
66        snapshot: &Value,
67        current: impl Fn() -> Result<(), String>,
68    ) -> Result<(), String> {
69        if let Some(status) = self.parental_status_admitted(current)? {
70            admit(
71                &status,
72                snapshot,
73                self.account["id"].as_str().unwrap_or_default(),
74            )?;
75        }
76        Ok(())
77    }
78    /// Admit retained images with at most one second of monotonic policy grace, shared only by clones of this login.
79    ///
80    /// This cache never admits audio, downloads, output changes or a freshly read catalog. Expired or
81    /// failed reads fail closed; a failure is not retained. Network and cached images use the same
82    /// projection validation, while native process generations still fence account replacements.
83    pub fn admit_artwork_snapshot(&self, snapshot: &Value) -> Result<(), String> {
84        self.admit_artwork_snapshot_admitted(snapshot, || Ok(()))
85    }
86    /// Native session admission remains independent of the one-second policy cache.
87    pub fn admit_artwork_snapshot_admitted(
88        &self,
89        snapshot: &Value,
90        current: impl Fn() -> Result<(), String>,
91    ) -> Result<(), String> {
92        current()?;
93        if self.account["role"] == "admin" || !self.account["parental_enabled"].is_boolean() {
94            return Ok(());
95        }
96        let mut cached = self
97            .artwork_parental
98            .lock()
99            .map_err(|_| "État du contrôle parental indisponible.")?;
100        if cached
101            .as_ref()
102            .is_some_and(|(checked, _)| checked.elapsed() >= std::time::Duration::from_secs(1))
103        {
104            *cached = None;
105        }
106        if cached.is_none() {
107            // Start the bound before IO, so even a slow response cannot extend the grace period.
108            let checked = std::time::Instant::now();
109            if let Some(status) = self.parental_status_admitted(&current)? {
110                *cached = Some((checked, status));
111            }
112        }
113        if let Some((_, status)) = cached.as_ref() {
114            admit(
115                status,
116                snapshot,
117                self.account["id"].as_str().unwrap_or_default(),
118            )?;
119        }
120        Ok(())
121    }
122    /// Episode files cannot carry the account's continuous listening limits into another player.
123    pub fn require_unrestricted_download(&self) -> Result<(), String> {
124        self.require_unrestricted_download_admitted(|| Ok(()))
125    }
126    /// Retire the policy lookup before another account or cancelled transfer can dispatch a retry.
127    pub fn require_unrestricted_download_admitted(
128        &self,
129        current: impl Fn() -> Result<(), String>,
130    ) -> Result<(), String> {
131        if self
132            .external_parental_status_admitted(current)?
133            .is_some_and(|v| v["policy"]["enabled"] == true)
134        {
135            return Err(
136                "Le téléchargement des épisodes est indisponible pour un compte supervisé.".into(),
137            );
138        }
139        Ok(())
140    }
141    /// An external player cannot enforce this server's continuous listening limits.
142    pub fn require_unrestricted_output(&self) -> Result<(), String> {
143        self.require_unrestricted_output_admitted(|| Ok(()))
144    }
145    /// Output preparation retains its caller's cancellation and native session admission.
146    pub fn require_unrestricted_output_admitted(
147        &self,
148        current: impl Fn() -> Result<(), String>,
149    ) -> Result<(), String> {
150        if self
151            .external_parental_status_admitted(current)?
152            .is_some_and(|v| v["policy"]["enabled"] == true)
153        {
154            return Err("HQPlayer est indisponible pour un compte supervisé.".into());
155        }
156        Ok(())
157    }
158}
159
160fn admit(status: &Value, snapshot: &Value, owner: &str) -> Result<(), String> {
161    if status["policy"]["enabled"] == true
162        && (snapshot["accountId"] != owner
163            || snapshot["parentalEnabled"] != true
164            || snapshot["parentalRevision"] != status["revision"]
165            || snapshot["parentalPolicy"] != status["policy"])
166    {
167        return Err("Catalogue supervisé à actualiser. Synchronisez la bibliothèque.".into());
168    }
169    Ok(())
170}
171fn validate(value: &Value, owner: &str) -> Result<(), String> {
172    if value["owner"] != owner
173        || !value["policy"]["enabled"].is_boolean()
174        || value["revision"].as_u64().is_none()
175        || !value["allowed"].is_boolean()
176    {
177        return Err("État du contrôle parental invalide ou réservé à un autre compte.".into());
178    }
179    Ok(())
180}
181#[cfg(test)]
182#[path = "network_parental_tests.rs"]
183mod tests;