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 url, base64'd — the same key the extension
4//! files (`emoteBlockKey`: btoa(url)) and the server stores, because refs carry
5//! no hash. two emotes sharing a name (7tv `W`, bttv `W`) are two keys; a
6//! friend's same-named emote is a third. blocking never touches a name.
7//!
8//! the set is process-global: layout and completion consult it without every
9//! caller threading it through. the face loads it from config at start and
10//! persists after every change.
11
12use crate::emote::Emote;
13use std::collections::HashMap;
14use std::sync::RwLock;
15
16/// the server's block-hash column is varchar(255): longer keys can't be stored
17/// or synced, so they are refused rather than truncated into a key that matches
18/// nothing and can never be unblocked.
19pub const KEY_MAX: usize = 255;
20/// blocks kept — a runaway sync can't grow the set without bound.
21pub 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
65/// the block key of an emote: btoa(url). None when it won't fit the server.
66pub 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
75/// what a key points at, for display: the url's last two path parts.
76pub 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/// the blocked emotes: key → the name it was blocked under ("" when learned
92/// from the account and never seen here yet), in the order they were blocked.
93#[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    /// true when newly added. a known key learns its name if it had none.
113    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    /// the name it was filed under, when it was blocked.
132    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    /// (key, name) in block order.
139    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    /// keys blocked under this name (exact, case-sensitive).
147    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    /// is this exact emote blocked — by url key, or by the provider id the
156    /// extension files long ids under (ulids, bttv hex; short numeric ids are
157    /// not unique across providers, so they never match).
158    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
189/// is this exact emote hidden right now.
190pub fn is_blocked(e: &Emote) -> bool {
191    read(|b| b.hides(e))
192}
193
194/// block this exact emote. false when already blocked or unkeyable.
195pub 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
202/// unblock by key; the name it was filed under.
203pub 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
215/// replace the whole set (startup, from config).
216pub 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
225/// fold in the account's blocks (keys only): anything new is added, nothing
226/// local is dropped. returns how many were new.
227pub 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        // known btoa vectors
250        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(), ""); // learned from the account: no name yet
300        assert_eq!(s.keys_named("W"), vec![key(&a).unwrap()]);
301        assert_eq!(s.list().len(), 2);
302        // seeing the emote teaches the nameless key its name
303        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        // unique url: other tests in this binary never touch it
336        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}