1#[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
53fn 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
169pub 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
179pub 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 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 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}