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