Skip to main content

heatsync_core/
blockid.rs

1//! what an emote is filed under when blocked — the same rule the extension,
2//! the server, desktop and chatterino use, so a block made in any client hides
3//! the emote in every other. byte-for-byte parity is pinned by the vectors in
4//! the tests (shared with desktop).
5//!
6//! provider urls (7tv/bttv/kick/twitch/ffz) file under the provider's own emote
7//! id (ffz as `ffz-<id>`); everything else under sha256 of the normalised url.
8
9/// where an emote came from. pooled globals (`/api/emotes`) are filed by sha256
10/// — except twitch's own, by id; a channel's set by provider id.
11#[derive(Clone, Copy, PartialEq, Eq, Debug)]
12pub enum Scope {
13    Hs,
14    Channel,
15    Global,
16    Native,
17}
18
19#[derive(Clone, Copy, PartialEq, Eq)]
20enum Kind {
21    Plain,
22    Ffz,
23    Twitch,
24}
25
26fn valid_id(h: &str) -> bool {
27    !h.is_empty()
28        && h.len() <= 255
29        && h.bytes()
30            .all(|b| b.is_ascii_alphanumeric() || b"+/_-=".contains(&b))
31}
32
33fn id_after<'a>(url: &'a str, host: &str, marker: &str) -> Option<&'a str> {
34    let rest = url.strip_prefix("https://")?;
35    let (h, path) = rest.split_once('/')?;
36    if h != host && !h.ends_with(&format!(".{host}")) {
37        return None;
38    }
39    let id = path.strip_prefix(marker)?.split(['/', '?', '#']).next()?;
40    valid_id(id).then_some(id)
41}
42
43fn provider_id(url: &str) -> Option<(Kind, &str)> {
44    let p = |host, marker| id_after(url, host, marker);
45    p("7tv.app", "emote/")
46        .or_else(|| p("betterttv.net", "emote/"))
47        .or_else(|| p("kick.com", "emotes/"))
48        .map(|id| (Kind::Plain, id))
49        .or_else(|| p("frankerfacez.com", "emote/").map(|id| (Kind::Ffz, id)))
50        .or_else(|| p("jtvnw.net", "emoticons/v2/").map(|id| (Kind::Twitch, id)))
51}
52
53/// the server's url normalisation before it hashes: a 7tv avif is its webp,
54/// first-party origins drop to a path.
55fn normalized(url: &str) -> String {
56    let mut u = url.to_owned();
57    for pre in [
58        "https://cdn.7tv.app/emote/",
59        "http://cdn.7tv.app/emote/",
60        "//cdn.7tv.app/emote/",
61    ] {
62        if let Some(rest) = u.strip_prefix(pre) {
63            if let Some((id, file)) = rest.split_once('/') {
64                if !id.is_empty() {
65                    let (name, tail) = file
66                        .find(['?', '#'])
67                        .map_or((file, ""), |i| file.split_at(i));
68                    if let Some(stem) = name.strip_suffix(".avif") {
69                        let size = stem.strip_suffix("_static").unwrap_or(stem);
70                        if matches!(size, "1x" | "2x" | "3x" | "4x") {
71                            u = format!("{pre}{id}/{stem}.webp{tail}");
72                        }
73                    }
74                    break;
75                }
76            }
77        }
78    }
79    for origin in [
80        "https://cdn.heatsync.org",
81        "https://heatsync.org",
82        "http://heatsync.org",
83        "https://www.heatsync.org",
84        "http://www.heatsync.org",
85        "http://localhost:3001",
86        "http://localhost:5173",
87    ] {
88        if u == origin {
89            return "/".into();
90        }
91        if let Some(rest) = u.strip_prefix(origin) {
92            if rest.starts_with('/') {
93                return rest.to_owned();
94            }
95        }
96    }
97    u
98}
99
100const K: [u32; 64] = [
101    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
102    0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
103    0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
104    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
105    0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
106    0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
107    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
108    0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
109];
110
111fn sha256_hex(data: &[u8]) -> String {
112    let mut h: [u32; 8] = [
113        0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
114        0x5be0cd19,
115    ];
116    let mut msg = data.to_vec();
117    msg.push(0x80);
118    while msg.len() % 64 != 56 {
119        msg.push(0);
120    }
121    msg.extend_from_slice(&((data.len() as u64) * 8).to_be_bytes());
122    for chunk in msg.chunks(64) {
123        let mut w = [0u32; 64];
124        for (i, b) in chunk.chunks(4).enumerate() {
125            w[i] = u32::from_be_bytes([b[0], b[1], b[2], b[3]]);
126        }
127        for i in 16..64 {
128            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
129            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
130            w[i] = w[i - 16]
131                .wrapping_add(s0)
132                .wrapping_add(w[i - 7])
133                .wrapping_add(s1);
134        }
135        let mut v = h;
136        for i in 0..64 {
137            let s1 = v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25);
138            let ch = (v[4] & v[5]) ^ (!v[4] & v[6]);
139            let t1 = v[7]
140                .wrapping_add(s1)
141                .wrapping_add(ch)
142                .wrapping_add(K[i])
143                .wrapping_add(w[i]);
144            let s0 = v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22);
145            let maj = (v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]);
146            let t2 = s0.wrapping_add(maj);
147            v = [
148                t1.wrapping_add(t2),
149                v[0],
150                v[1],
151                v[2],
152                v[3].wrapping_add(t1),
153                v[4],
154                v[5],
155                v[6],
156            ];
157        }
158        for (a, b) in h.iter_mut().zip(v) {
159            *a = a.wrapping_add(b);
160        }
161    }
162    h.iter().map(|x| format!("{x:08x}")).collect()
163}
164
165fn sha_of_url(url: &str) -> String {
166    sha256_hex(normalized(url).as_bytes())
167}
168
169/// the key a block of this emote is filed under.
170pub fn emote_hash(url: &str, scope: Scope) -> String {
171    match (provider_id(url), scope) {
172        (Some((Kind::Twitch, id)), _) => id.to_owned(),
173        (Some((Kind::Plain, id)), Scope::Channel | Scope::Native) => id.to_owned(),
174        (Some((Kind::Ffz, id)), Scope::Channel | Scope::Native) => format!("ffz-{id}"),
175        _ => sha_of_url(url),
176    }
177}
178
179/// every key a block of this emote might be filed under by any client.
180pub fn all_forms(url: &str) -> Vec<String> {
181    let mut v = Vec::with_capacity(3);
182    if let Some((k, id)) = provider_id(url) {
183        v.push(id.to_owned());
184        if k == Kind::Ffz {
185            v.push(format!("ffz-{id}"));
186        }
187        if k == Kind::Twitch {
188            return v;
189        }
190    }
191    v.push(sha_of_url(url));
192    v
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    // shared with desktop (heatsync-chat/src/emotes/block.rs): same vectors, same bytes
200    const SEVENTV: &str = "https://cdn.7tv.app/emote/01ABC/1x.webp";
201    const SEVENTV_SHA: &str = "b77bff3d9068dbd6ac26734d183ecb9480e63250de106a79ab1c317d0360dc65";
202
203    #[test]
204    fn sha256_known_vectors() {
205        assert_eq!(
206            sha256_hex(b""),
207            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
208        );
209        assert_eq!(
210            sha256_hex(b"abc"),
211            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
212        );
213        // multi-block
214        assert_eq!(
215            sha256_hex(&[b'a'; 1000]),
216            "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3"
217        );
218    }
219
220    #[test]
221    fn hash_parity_per_source() {
222        assert_eq!(emote_hash(SEVENTV, Scope::Channel), "01ABC");
223        assert_eq!(
224            emote_hash(
225                "https://cdn.betterttv.net/emote/5f1b/1x.webp",
226                Scope::Channel
227            ),
228            "5f1b"
229        );
230        assert_eq!(
231            emote_hash(
232                "https://cdn.frankerfacez.com/emote/381875/1",
233                Scope::Channel
234            ),
235            "ffz-381875"
236        );
237        assert_eq!(
238            emote_hash("https://files.kick.com/emotes/777/fullsize", Scope::Channel),
239            "777"
240        );
241        let tw = "https://static-cdn.jtvnw.net/emoticons/v2/25/default/dark/1.0";
242        for sc in [Scope::Hs, Scope::Channel, Scope::Global, Scope::Native] {
243            assert_eq!(emote_hash(tw, sc), "25");
244        }
245        assert_eq!(emote_hash(SEVENTV, Scope::Hs), SEVENTV_SHA);
246        assert_eq!(emote_hash(SEVENTV, Scope::Global), SEVENTV_SHA);
247        assert_eq!(
248            emote_hash("https://cdn.betterttv.net/emote/5f1b/1x.webp", Scope::Hs),
249            "710451d50450b9b8da58fb1f07c244b16ef0b0c4c66c24ba632fb1dd77725fb1"
250        );
251        assert_eq!(
252            emote_hash("https://cdn.frankerfacez.com/emote/381875/1", Scope::Global),
253            "83c8be4f483c6ac93edbac7f132bdec8685e0a4c670c79b7d8d18cd0ffa97b2e"
254        );
255    }
256
257    #[test]
258    fn sha_normalises_like_the_server() {
259        let want = "4cd6546ec9fb7e1ac16b8d09c7ebfd662df89c94ec09e723dff5c5abc516d91f";
260        for u in [
261            "https://cdn.heatsync.org/uploads/a.webp",
262            "https://heatsync.org/uploads/a.webp",
263            "/uploads/a.webp",
264        ] {
265            assert_eq!(emote_hash(u, Scope::Hs), want, "{u}");
266        }
267        assert_ne!(
268            emote_hash(
269                "https://cdn.heatsync.org.evil.com/uploads/a.webp",
270                Scope::Hs
271            ),
272            want
273        );
274        assert_eq!(
275            emote_hash("https://cdn.7tv.app/emote/01ABC/1x.avif", Scope::Hs),
276            SEVENTV_SHA
277        );
278        assert_eq!(
279            normalized("https://cdn.7tv.app/emote/01ABC/2x_static.avif?v=1"),
280            "https://cdn.7tv.app/emote/01ABC/2x_static.webp?v=1"
281        );
282        assert_eq!(
283            normalized("https://cdn.7tv.app/emote/01ABC/1x.gif"),
284            "https://cdn.7tv.app/emote/01ABC/1x.gif"
285        );
286    }
287
288    #[test]
289    fn every_form_is_listed() {
290        assert_eq!(
291            all_forms(SEVENTV),
292            vec!["01ABC".to_owned(), SEVENTV_SHA.to_owned()]
293        );
294        let ffz = all_forms("https://cdn.frankerfacez.com/emote/381875/1");
295        assert_eq!(&ffz[..2], ["381875", "ffz-381875"]);
296        assert_eq!(ffz.len(), 3);
297        assert_eq!(
298            all_forms("https://static-cdn.jtvnw.net/emoticons/v2/25/default/dark/1.0"),
299            vec!["25".to_owned()]
300        );
301    }
302}