1use crate::emote::Emote;
13use std::collections::HashMap;
14use std::sync::RwLock;
15
16pub const KEY_MAX: usize = 255;
20pub const CAP: usize = 5000;
22
23const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
24
25fn b64(bytes: &[u8]) -> String {
26 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
27 for c in bytes.chunks(3) {
28 let n = (u32::from(c[0]) << 16)
29 | (u32::from(*c.get(1).unwrap_or(&0)) << 8)
30 | u32::from(*c.get(2).unwrap_or(&0));
31 for i in 0..4 {
32 if i <= c.len() {
33 out.push(B64[(n >> (18 - 6 * i) & 63) as usize] as char);
34 } else {
35 out.push('=');
36 }
37 }
38 }
39 out
40}
41
42fn unb64(s: &str) -> Option<Vec<u8>> {
43 let mut out = Vec::with_capacity(s.len() / 4 * 3);
44 let (mut acc, mut bits) = (0u32, 0u32);
45 for b in s.trim_end_matches('=').bytes() {
46 let v = match b {
47 b'A'..=b'Z' => b - b'A',
48 b'a'..=b'z' => b - b'a' + 26,
49 b'0'..=b'9' => b - b'0' + 52,
50 b'+' | b'-' => 62,
51 b'/' | b'_' => 63,
52 _ => return None,
53 };
54 acc = (acc << 6) | u32::from(v);
55 bits += 6;
56 if bits >= 8 {
57 bits -= 8;
58 out.push((acc >> bits) as u8);
59 acc &= (1 << bits) - 1;
60 }
61 }
62 Some(out)
63}
64
65pub fn key_of_url(url: &str) -> Option<String> {
67 let k = b64(url.as_bytes());
68 (!url.is_empty() && k.len() <= KEY_MAX).then_some(k)
69}
70
71pub fn key(e: &Emote) -> Option<String> {
72 key_of_url(&e.url)
73}
74
75pub fn describe(key: &str) -> String {
77 let url = unb64(key).and_then(|b| String::from_utf8(b).ok());
78 match url {
79 Some(u) => {
80 let tail: Vec<&str> = u.rsplit('/').take(2).collect();
81 format!(
82 "{}/{}",
83 tail.get(1).unwrap_or(&""),
84 tail.first().unwrap_or(&"")
85 )
86 }
87 None => key.chars().take(12).collect(),
88 }
89}
90
91#[derive(Default, Debug, Clone, PartialEq)]
94pub struct Blocks {
95 names: HashMap<String, String>,
96 order: Vec<String>,
97}
98
99impl Blocks {
100 pub fn is_empty(&self) -> bool {
101 self.order.is_empty()
102 }
103
104 pub fn len(&self) -> usize {
105 self.order.len()
106 }
107
108 pub fn has(&self, key: &str) -> bool {
109 self.names.contains_key(key)
110 }
111
112 pub fn add(&mut self, key: String, name: &str) -> bool {
114 if key.len() > KEY_MAX || key.is_empty() {
115 return false;
116 }
117 if let Some(n) = self.names.get_mut(&key) {
118 if n.is_empty() {
119 *n = name.to_string();
120 }
121 return false;
122 }
123 if self.order.len() >= CAP {
124 return false;
125 }
126 self.order.push(key.clone());
127 self.names.insert(key, name.to_string());
128 true
129 }
130
131 pub fn remove(&mut self, key: &str) -> Option<String> {
133 let n = self.names.remove(key)?;
134 self.order.retain(|k| k != key);
135 Some(n)
136 }
137
138 pub fn list(&self) -> Vec<(String, String)> {
140 self.order
141 .iter()
142 .map(|k| (k.clone(), self.names[k].clone()))
143 .collect()
144 }
145
146 pub fn keys_named(&self, name: &str) -> Vec<String> {
148 self.order
149 .iter()
150 .filter(|k| self.names[*k] == name)
151 .cloned()
152 .collect()
153 }
154
155 pub fn hides(&self, e: &Emote) -> bool {
159 if self.order.is_empty() {
160 return false;
161 }
162 if key(e).is_some_and(|k| self.has(&k)) {
163 return true;
164 }
165 e.id.len() >= 8 && self.has(&e.id)
166 }
167}
168
169static STORE: RwLock<Option<Blocks>> = RwLock::new(None);
170
171fn read<R>(f: impl FnOnce(&Blocks) -> R) -> R {
172 match STORE.read() {
173 Ok(g) => match g.as_ref() {
174 Some(b) => f(b),
175 None => f(&Blocks::default()),
176 },
177 Err(p) => match p.into_inner().as_ref() {
178 Some(b) => f(b),
179 None => f(&Blocks::default()),
180 },
181 }
182}
183
184fn write<R>(f: impl FnOnce(&mut Blocks) -> R) -> R {
185 let mut g = STORE.write().unwrap_or_else(|p| p.into_inner());
186 f(g.get_or_insert_with(Blocks::default))
187}
188
189pub fn is_blocked(e: &Emote) -> bool {
191 read(|b| b.hides(e))
192}
193
194pub fn block(e: &Emote) -> bool {
196 match key(e) {
197 Some(k) => write(|b| b.add(k, &e.name)),
198 None => false,
199 }
200}
201
202pub fn unblock_key(key: &str) -> Option<String> {
204 write(|b| b.remove(key))
205}
206
207pub fn list() -> Vec<(String, String)> {
208 read(Blocks::list)
209}
210
211pub fn keys_named(name: &str) -> Vec<String> {
212 read(|b| b.keys_named(name))
213}
214
215pub fn load(entries: impl IntoIterator<Item = (String, String)>) {
217 write(|b| {
218 *b = Blocks::default();
219 for (k, n) in entries {
220 b.add(k, &n);
221 }
222 })
223}
224
225pub fn merge_keys(keys: impl IntoIterator<Item = String>) -> usize {
228 write(|b| keys.into_iter().filter(|k| b.add(k.clone(), "")).count())
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn em(name: &str, url: &str, id: &str) -> Emote {
236 Emote {
237 name: name.into(),
238 url: url.into(),
239 provider: "7tv".into(),
240 id: id.into(),
241 animated: false,
242 zero_width: false,
243 global: false,
244 }
245 }
246
247 #[test]
248 fn key_is_btoa_of_the_url() {
249 assert_eq!(b64(b""), "");
251 assert_eq!(b64(b"f"), "Zg==");
252 assert_eq!(b64(b"fo"), "Zm8=");
253 assert_eq!(b64(b"foo"), "Zm9v");
254 assert_eq!(b64(b"foobar"), "Zm9vYmFy");
255 let u = "https://cdn.7tv.app/emote/01AAA/2x.webp";
256 assert_eq!(
257 key_of_url(u).unwrap(),
258 "aHR0cHM6Ly9jZG4uN3R2LmFwcC9lbW90ZS8wMUFBQS8yeC53ZWJw"
259 );
260 assert_eq!(unb64(&key_of_url(u).unwrap()).unwrap(), u.as_bytes());
261 assert!(key_of_url("").is_none());
262 assert!(
263 key_of_url(&"x".repeat(200)).is_none(),
264 "won't fit 255 once encoded"
265 );
266 }
267
268 #[test]
269 fn blocks_the_exact_emote_never_the_name() {
270 let a = em("W", "https://cdn.7tv.app/emote/A/2x.webp", "");
271 let b = em("W", "https://cdn.betterttv.net/emote/B/2x.webp", "");
272 let mut s = Blocks::default();
273 assert!(s.add(key(&a).unwrap(), "W"));
274 assert!(s.hides(&a));
275 assert!(!s.hides(&b), "same name, other emote: still shown");
276 assert!(!s.add(key(&a).unwrap(), "W"), "second block is a no-op");
277 assert_eq!(s.len(), 1);
278 }
279
280 #[test]
281 fn ids_match_only_when_long_enough_to_be_unique() {
282 let long = em("X", "https://c/x", "01HABCDEFGHJKMNPQRSTVWXYZ0");
283 let short = em("K", "https://c/k", "25");
284 let mut s = Blocks::default();
285 s.add("01HABCDEFGHJKMNPQRSTVWXYZ0".into(), "X");
286 s.add("25".into(), "K");
287 assert!(s.hides(&long), "a long id the extension filed");
288 assert!(
289 !s.hides(&short),
290 "short numeric ids collide across providers"
291 );
292 }
293
294 #[test]
295 fn unblock_list_names_and_sync_merge() {
296 let a = em("W", "https://c/a", "");
297 let mut s = Blocks::default();
298 s.add(key(&a).unwrap(), "W");
299 s.add("c3luY2Vk".into(), ""); assert_eq!(s.keys_named("W"), vec![key(&a).unwrap()]);
301 assert_eq!(s.list().len(), 2);
302 s.add("c3luY2Vk".into(), "Seen");
304 assert_eq!(s.list()[1].1, "Seen");
305 assert_eq!(s.remove(&key(&a).unwrap()).as_deref(), Some("W"));
306 assert!(s.remove(&key(&a).unwrap()).is_none());
307 assert!(!s.hides(&a));
308 assert_eq!(s.len(), 1);
309 }
310
311 #[test]
312 fn bounded_and_junk_refused() {
313 let mut s = Blocks::default();
314 assert!(!s.add(String::new(), "x"));
315 assert!(!s.add("k".repeat(KEY_MAX + 1), "x"));
316 for i in 0..CAP + 10 {
317 s.add(format!("k{i}"), "n");
318 }
319 assert_eq!(s.len(), CAP);
320 }
321
322 #[test]
323 fn describe_shows_where_a_key_points() {
324 let k = key_of_url("https://cdn.7tv.app/emote/01AAA/2x.webp").unwrap();
325 assert_eq!(describe(&k), "01AAA/2x.webp");
326 assert_eq!(
327 describe("!!notbase64!!"),
328 "!!notbase64!",
329 "not base64: the first 12 chars"
330 );
331 }
332
333 #[test]
334 fn the_global_set_hides_and_unhides() {
335 let e = em("GlobalBlockTest", "https://block.test/global/1x.webp", "");
337 assert!(!is_blocked(&e));
338 assert!(block(&e));
339 assert!(is_blocked(&e));
340 assert!(!block(&e));
341 assert_eq!(
342 unblock_key(&key(&e).unwrap()).as_deref(),
343 Some("GlobalBlockTest")
344 );
345 assert!(!is_blocked(&e));
346 }
347}