aede_core/parental/
policy.rs1use super::feeds::canonical_feed;
3use crate::model::EntityKind;
4use crate::user::EntityRef;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct Window {
11 pub start_minute: u16,
13 pub end_minute: u16,
15}
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct DayPolicy {
20 pub windows: Vec<Window>,
22 pub limit_seconds: Option<u32>,
24}
25impl Default for DayPolicy {
26 fn default() -> Self {
27 Self {
28 windows: vec![Window {
29 start_minute: 0,
30 end_minute: 1440,
31 }],
32 limit_seconds: None,
33 }
34 }
35}
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct Policy {
40 pub enabled: bool,
42 pub timezone: String,
44 pub blocked_artists: Vec<String>,
46 pub radio_allowed: bool,
48 #[serde(default = "allow_podcasts")]
50 pub podcasts_allowed: bool,
51 pub blocked_podcasts: Vec<String>,
53 pub days: [DayPolicy; 7],
55 pub week_limit_seconds: Option<u32>,
57}
58impl Default for Policy {
59 fn default() -> Self {
60 Self {
61 enabled: false,
62 timezone: "UTC".into(),
63 blocked_artists: Vec::new(),
64 radio_allowed: true,
65 podcasts_allowed: true,
66 blocked_podcasts: Vec::new(),
67 days: std::array::from_fn(|_| DayPolicy::default()),
68 week_limit_seconds: None,
69 }
70 }
71}
72fn allow_podcasts() -> bool {
73 true
74}
75impl Policy {
76 pub fn validate(&self) -> Result<(), String> {
78 if self.timezone.len() > 128 || jiff::tz::TimeZone::get(&self.timezone).is_err() {
79 return Err("invalid parental time zone".into());
80 }
81 if self.blocked_artists.len() > 256
82 || self.blocked_podcasts.len() > 128
83 || self.week_limit_seconds.is_some_and(|limit| limit > 604_800)
84 {
85 return Err("parental policy exceeds its bounds".into());
86 }
87 for token in &self.blocked_artists {
88 let reference =
89 EntityRef::parse_token(token).ok_or("invalid blocked artist reference")?;
90 if reference.kind != EntityKind::Artist
91 || reference.key.is_empty()
92 || token.len() > 16_384
93 || reference.key.starts_with("@phemios/")
94 || token.chars().any(char::is_control)
95 {
96 return Err("invalid blocked artist reference".into());
97 }
98 }
99 for feed in &self.blocked_podcasts {
100 canonical_feed(feed)?;
101 }
102 for day in &self.days {
103 if day.windows.len() > 8 || day.limit_seconds.is_some_and(|limit| limit > 86_400) {
104 return Err("parental day exceeds its bounds".into());
105 }
106 let mut windows = day.windows.iter().collect::<Vec<_>>();
107 windows.sort_by_key(|window| window.start_minute);
108 let mut previous_end = 0;
109 for window in windows {
110 if window.start_minute >= window.end_minute
111 || window.end_minute > 1440
112 || window.start_minute < previous_end
113 {
114 return Err(
115 "parental windows must be ordered, non-overlapping intervals".into(),
116 );
117 }
118 previous_end = window.end_minute;
119 }
120 }
121 Ok(())
122 }
123 pub fn canonicalize(mut self) -> Result<Self, String> {
125 self.validate()?;
126 for feed in &mut self.blocked_podcasts {
127 *feed = canonical_feed(feed)?;
128 }
129 for day in &mut self.days {
130 day.windows.sort_by_key(|window| window.start_minute);
131 }
132 self.blocked_artists.sort();
133 self.blocked_artists.dedup();
134 self.blocked_podcasts.sort();
135 self.blocked_podcasts.dedup();
136 Ok(self)
137 }
138}
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
142pub enum Media {
143 Music {
145 track: Option<String>,
147 },
148 Radio,
150 Podcast {
152 feed: String,
154 },
155}
156#[derive(Debug, Clone, Serialize)]
158pub struct PolicySnapshot {
159 pub owner: String,
161 pub revision: u64,
163 pub locked_by: Option<String>,
165 pub policy: Policy,
167}
168#[derive(Debug, Clone, Serialize)]
170pub struct Status {
171 pub owner: String,
173 pub revision: u64,
175 pub policy: Policy,
177 pub day_used_seconds: u64,
179 pub week_used_seconds: u64,
181 pub remaining_seconds: Option<u64>,
183 pub allowed: bool,
185 pub reason: Option<String>,
187 pub next_change_at: Option<u64>,
189}
190
191#[cfg(test)]
192#[path = "policy_tests.rs"]
193mod tests;