Skip to main content

heatsync_core/
block.rs

1//! per-emote blocks: hide one exact emote, never a name.
2//!
3//! a block is keyed by the emote's identity (see `blockid`): the provider's own
4//! id for 7tv/bttv/kick/ffz/twitch urls, else sha256 of the normalised url — the
5//! same key the extension, desktop and chatterino file, so a block made in any
6//! client hides the emote in every other. a block an older build filed as
7//! btoa(url) is still honoured. two emotes sharing a name (7tv `W`, bttv `W`)
8//! are two keys; blocking never touches a name.
9//!
10//! the set is process-global: layout and completion consult it without every
11//! caller threading it through. the face loads it from config at start and
12//! persists after every change.
13
14use crate::blockid::{all_forms, emote_hash, Scope};
15use crate::emote::Emote;
16use std::collections::HashMap;
17use std::sync::RwLock;
18
19/// the server's block-hash column is varchar(255): longer keys can't be stored
20/// or synced, so they are refused rather than truncated into a key that matches
21/// nothing and can never be unblocked.
22pub const KEY_MAX: usize = 255;
23/// blocks kept — a runaway sync can't grow the set without bound.
24pub 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
68/// the old key shape (btoa of the url), kept so existing blocks still match.
69/// None when it won't fit the server.
70fn 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
75/// the key a new block of this emote is filed under. None when it has no url.
76pub 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
88/// every key this emote could be blocked under, any client or build.
89fn 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
98/// what a key points at, for display: the url's last two path parts.
99pub 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/// the blocked emotes: key → the name it was blocked under ("" when learned
117/// from the account and never seen here yet), in the order they were blocked.
118#[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    /// true when newly added. a known key learns its name if it had none.
138    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    /// the name it was filed under, when it was blocked.
157    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    /// (key, name) in block order.
164    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    /// keys blocked under this name (exact, case-sensitive).
172    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    /// is this exact emote blocked — under any key any client files it by.
181    pub fn hides(&self, e: &Emote) -> bool {
182        !self.order.is_empty() && forms(e).iter().any(|k| self.has(k))
183    }
184
185    /// the keys held for this exact emote (to unblock every form).
186    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
211/// is this exact emote hidden right now.
212pub fn is_blocked(e: &Emote) -> bool {
213    read(|b| b.hides(e))
214}
215
216/// block this exact emote. false when already blocked or unkeyable.
217pub 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
224/// the keys held for this exact emote.
225pub fn held(e: &Emote) -> Vec<String> {
226    read(|b| b.held(e))
227}
228
229/// unblock by key; the name it was filed under.
230pub 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
242/// replace the whole set (startup, from config).
243pub 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
252/// fold in the account's blocks (keys only): anything new is added, nothing
253/// local is dropped. returns how many were new.
254pub 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        // known btoa vectors
277        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(), ""); // learned from the account: no name yet
344        assert_eq!(s.keys_named("W"), vec![key(&a).unwrap()]);
345        assert_eq!(s.list().len(), 2);
346        // seeing the emote teaches the nameless key its name
347        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        // unique url: other tests in this binary never touch it
380        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}