···1+{
2+ "$type": "app.bsky.labeler.service",
3+ "policies": {
4+ "labelValues": [
5+ "joined-nov-a",
6+ "joined-dec-a",
7+ "joined-jan-b",
8+ "joined-feb-b",
9+ "joined-mar-b",
10+ "joined-apr-b",
11+ "joined-may-b",
12+ "joined-jun-b",
13+ "joined-jul-b",
14+ "joined-aug-b",
15+ "joined-sep-b",
16+ "joined-oct-b",
17+ "joined-nov-b",
18+ "joined-dec-b",
19+ "joined-jan-c",
20+ "joined-feb-c",
21+ "joined-mar-c",
22+ "joined-apr-c",
23+ "joined-may-c",
24+ "joined-jun-c",
25+ "joined-jul-c",
26+ "joined-aug-c",
27+ "joined-sep-c",
28+ "joined-oct-c",
29+ "joined-nov-c",
30+ "joined-dec-c",
31+ "joined-jan-d",
32+ "joined-feb-d",
33+ "joined-mar-d",
34+ "joined-apr-d",
35+ "joined-may-d",
36+ "joined-jun-d",
37+ "joined-jul-d",
38+ "joined-aug-d",
39+ "joined-sep-d",
40+ "joined-oct-d",
41+ "joined-nov-d",
42+ "joined-dec-d"
43+ ],
44+ "labelValueDefinitions": [
45+ {
46+ "blurs": "none",
47+ "locales": [
48+ {
49+ "lang": "en",
50+ "name": "Joined Nov ’22",
51+ "description": "This profile was created in November of 2022."
52+ }
53+ ],
54+ "severity": "inform",
55+ "adultOnly": false,
56+ "identifier": "joined-nov-a",
57+ "defaultSetting": "warn"
58+ },
59+ {
60+ "blurs": "none",
61+ "locales": [
62+ {
63+ "lang": "en",
64+ "name": "Joined Dec ’22",
65+ "description": "This profile was created in December of 2022."
66+ }
67+ ],
68+ "severity": "inform",
69+ "adultOnly": false,
70+ "identifier": "joined-dec-a",
71+ "defaultSetting": "warn"
72+ },
73+ {
74+ "blurs": "none",
75+ "locales": [
76+ {
77+ "lang": "en",
78+ "name": "Joined Jan ’23",
79+ "description": "This profile was created in January of 2023."
80+ }
81+ ],
82+ "severity": "inform",
83+ "adultOnly": false,
84+ "identifier": "joined-jan-b",
85+ "defaultSetting": "warn"
86+ },
87+ {
88+ "blurs": "none",
89+ "locales": [
90+ {
91+ "lang": "en",
92+ "name": "Joined Feb ’23",
93+ "description": "This profile was created in February of 2023."
94+ }
95+ ],
96+ "severity": "inform",
97+ "adultOnly": false,
98+ "identifier": "joined-feb-b",
99+ "defaultSetting": "warn"
100+ },
101+ {
102+ "blurs": "none",
103+ "locales": [
104+ {
105+ "lang": "en",
106+ "name": "Joined Mar ’23",
107+ "description": "This profile was created in March of 2023."
108+ }
109+ ],
110+ "severity": "inform",
111+ "adultOnly": false,
112+ "identifier": "joined-mar-b",
113+ "defaultSetting": "warn"
114+ },
115+ {
116+ "blurs": "none",
117+ "locales": [
118+ {
119+ "lang": "en",
120+ "name": "Joined Apr ’23",
121+ "description": "This profile was created in April of 2023."
122+ }
123+ ],
124+ "severity": "inform",
125+ "adultOnly": false,
126+ "identifier": "joined-apr-b",
127+ "defaultSetting": "warn"
128+ },
129+ {
130+ "blurs": "none",
131+ "locales": [
132+ {
133+ "lang": "en",
134+ "name": "Joined May ’23",
135+ "description": "This profile was created in May of 2023."
136+ }
137+ ],
138+ "severity": "inform",
139+ "adultOnly": false,
140+ "identifier": "joined-may-b",
141+ "defaultSetting": "warn"
142+ },
143+ {
144+ "blurs": "none",
145+ "locales": [
146+ {
147+ "lang": "en",
148+ "name": "Joined Jun ’23",
149+ "description": "This profile was created in June of 2023."
150+ }
151+ ],
152+ "severity": "inform",
153+ "adultOnly": false,
154+ "identifier": "joined-jun-b",
155+ "defaultSetting": "warn"
156+ },
157+ {
158+ "blurs": "none",
159+ "locales": [
160+ {
161+ "lang": "en",
162+ "name": "Joined Jul ’23",
163+ "description": "This profile was created in July of 2023."
164+ }
165+ ],
166+ "severity": "inform",
167+ "adultOnly": false,
168+ "identifier": "joined-jul-b",
169+ "defaultSetting": "warn"
170+ },
171+ {
172+ "blurs": "none",
173+ "locales": [
174+ {
175+ "lang": "en",
176+ "name": "Joined Aug ’23",
177+ "description": "This profile was created in August of 2023."
178+ }
179+ ],
180+ "severity": "inform",
181+ "adultOnly": false,
182+ "identifier": "joined-aug-b",
183+ "defaultSetting": "warn"
184+ },
185+ {
186+ "blurs": "none",
187+ "locales": [
188+ {
189+ "lang": "en",
190+ "name": "Joined Sep ’23",
191+ "description": "This profile was created in September of 2023."
192+ }
193+ ],
194+ "severity": "inform",
195+ "adultOnly": false,
196+ "identifier": "joined-sep-b",
197+ "defaultSetting": "warn"
198+ },
199+ {
200+ "blurs": "none",
201+ "locales": [
202+ {
203+ "lang": "en",
204+ "name": "Joined Oct ’23",
205+ "description": "This profile was created in October of 2023."
206+ }
207+ ],
208+ "severity": "inform",
209+ "adultOnly": false,
210+ "identifier": "joined-oct-b",
211+ "defaultSetting": "warn"
212+ },
213+ {
214+ "blurs": "none",
215+ "locales": [
216+ {
217+ "lang": "en",
218+ "name": "Joined Nov ’23",
219+ "description": "This profile was created in November of 2023."
220+ }
221+ ],
222+ "severity": "inform",
223+ "adultOnly": false,
224+ "identifier": "joined-nov-b",
225+ "defaultSetting": "warn"
226+ },
227+ {
228+ "blurs": "none",
229+ "locales": [
230+ {
231+ "lang": "en",
232+ "name": "Joined Dec ’23",
233+ "description": "This profile was created in December of 2023."
234+ }
235+ ],
236+ "severity": "inform",
237+ "adultOnly": false,
238+ "identifier": "joined-dec-b",
239+ "defaultSetting": "warn"
240+ },
241+ {
242+ "blurs": "none",
243+ "locales": [
244+ {
245+ "lang": "en",
246+ "name": "Joined Jan ’24",
247+ "description": "This profile was created in January of 2024."
248+ }
249+ ],
250+ "severity": "inform",
251+ "adultOnly": false,
252+ "identifier": "joined-jan-c",
253+ "defaultSetting": "warn"
254+ },
255+ {
256+ "blurs": "none",
257+ "locales": [
258+ {
259+ "lang": "en",
260+ "name": "Joined Feb ’24",
261+ "description": "This profile was created in February of 2024."
262+ }
263+ ],
264+ "severity": "inform",
265+ "adultOnly": false,
266+ "identifier": "joined-feb-c",
267+ "defaultSetting": "warn"
268+ },
269+ {
270+ "blurs": "none",
271+ "locales": [
272+ {
273+ "lang": "en",
274+ "name": "Joined Mar ’24",
275+ "description": "This profile was created in March of 2024."
276+ }
277+ ],
278+ "severity": "inform",
279+ "adultOnly": false,
280+ "identifier": "joined-mar-c",
281+ "defaultSetting": "warn"
282+ },
283+ {
284+ "blurs": "none",
285+ "locales": [
286+ {
287+ "lang": "en",
288+ "name": "Joined Apr ’24",
289+ "description": "This profile was created in April of 2024."
290+ }
291+ ],
292+ "severity": "inform",
293+ "adultOnly": false,
294+ "identifier": "joined-apr-c",
295+ "defaultSetting": "warn"
296+ },
297+ {
298+ "blurs": "none",
299+ "locales": [
300+ {
301+ "lang": "en",
302+ "name": "Joined May ’24",
303+ "description": "This profile was created in May of 2024."
304+ }
305+ ],
306+ "severity": "inform",
307+ "adultOnly": false,
308+ "identifier": "joined-may-c",
309+ "defaultSetting": "warn"
310+ },
311+ {
312+ "blurs": "none",
313+ "locales": [
314+ {
315+ "lang": "en",
316+ "name": "Joined Jun ’24",
317+ "description": "This profile was created in June of 2024."
318+ }
319+ ],
320+ "severity": "inform",
321+ "adultOnly": false,
322+ "identifier": "joined-jun-c",
323+ "defaultSetting": "warn"
324+ },
325+ {
326+ "blurs": "none",
327+ "locales": [
328+ {
329+ "lang": "en",
330+ "name": "Joined Jul ’24",
331+ "description": "This profile was created in July of 2024."
332+ }
333+ ],
334+ "severity": "inform",
335+ "adultOnly": false,
336+ "identifier": "joined-jul-c",
337+ "defaultSetting": "warn"
338+ },
339+ {
340+ "blurs": "none",
341+ "locales": [
342+ {
343+ "lang": "en",
344+ "name": "Joined Aug ’24",
345+ "description": "This profile was created in August of 2024."
346+ }
347+ ],
348+ "severity": "inform",
349+ "adultOnly": false,
350+ "identifier": "joined-aug-c",
351+ "defaultSetting": "warn"
352+ },
353+ {
354+ "blurs": "none",
355+ "locales": [
356+ {
357+ "lang": "en",
358+ "name": "Joined Sep ’24",
359+ "description": "This profile was created in September of 2024."
360+ }
361+ ],
362+ "severity": "inform",
363+ "adultOnly": false,
364+ "identifier": "joined-sep-c",
365+ "defaultSetting": "warn"
366+ },
367+ {
368+ "blurs": "none",
369+ "locales": [
370+ {
371+ "lang": "en",
372+ "name": "Joined Oct ’24",
373+ "description": "This profile was created in October of 2024."
374+ }
375+ ],
376+ "severity": "inform",
377+ "adultOnly": false,
378+ "identifier": "joined-oct-c",
379+ "defaultSetting": "warn"
380+ },
381+ {
382+ "blurs": "none",
383+ "locales": [
384+ {
385+ "lang": "en",
386+ "name": "Joined Nov ’24",
387+ "description": "This profile was created in November of 2024."
388+ }
389+ ],
390+ "severity": "inform",
391+ "adultOnly": false,
392+ "identifier": "joined-nov-c",
393+ "defaultSetting": "warn"
394+ },
395+ {
396+ "blurs": "none",
397+ "locales": [
398+ {
399+ "lang": "en",
400+ "name": "Joined Dec ’24",
401+ "description": "This profile was created in December of 2024."
402+ }
403+ ],
404+ "severity": "inform",
405+ "adultOnly": false,
406+ "identifier": "joined-dec-c",
407+ "defaultSetting": "warn"
408+ },
409+ {
410+ "blurs": "none",
411+ "locales": [
412+ {
413+ "lang": "en",
414+ "name": "Joined Jan ’25",
415+ "description": "This profile was created in January of 2025."
416+ }
417+ ],
418+ "severity": "inform",
419+ "adultOnly": false,
420+ "identifier": "joined-jan-d",
421+ "defaultSetting": "warn"
422+ },
423+ {
424+ "blurs": "none",
425+ "locales": [
426+ {
427+ "lang": "en",
428+ "name": "Joined Feb ’25",
429+ "description": "This profile was created in February of 2025."
430+ }
431+ ],
432+ "severity": "inform",
433+ "adultOnly": false,
434+ "identifier": "joined-feb-d",
435+ "defaultSetting": "warn"
436+ },
437+ {
438+ "blurs": "none",
439+ "locales": [
440+ {
441+ "lang": "en",
442+ "name": "Joined Mar ’25",
443+ "description": "This profile was created in March of 2025."
444+ }
445+ ],
446+ "severity": "inform",
447+ "adultOnly": false,
448+ "identifier": "joined-mar-d",
449+ "defaultSetting": "warn"
450+ },
451+ {
452+ "blurs": "none",
453+ "locales": [
454+ {
455+ "lang": "en",
456+ "name": "Joined Apr ’25",
457+ "description": "This profile was created in April of 2025."
458+ }
459+ ],
460+ "severity": "inform",
461+ "adultOnly": false,
462+ "identifier": "joined-apr-d",
463+ "defaultSetting": "warn"
464+ },
465+ {
466+ "blurs": "none",
467+ "locales": [
468+ {
469+ "lang": "en",
470+ "name": "Joined May ’25",
471+ "description": "This profile was created in May of 2025."
472+ }
473+ ],
474+ "severity": "inform",
475+ "adultOnly": false,
476+ "identifier": "joined-may-d",
477+ "defaultSetting": "warn"
478+ },
479+ {
480+ "blurs": "none",
481+ "locales": [
482+ {
483+ "lang": "en",
484+ "name": "Joined Jun ’25",
485+ "description": "This profile was created in June of 2025."
486+ }
487+ ],
488+ "severity": "inform",
489+ "adultOnly": false,
490+ "identifier": "joined-jun-d",
491+ "defaultSetting": "warn"
492+ },
493+ {
494+ "blurs": "none",
495+ "locales": [
496+ {
497+ "lang": "en",
498+ "name": "Joined Jul ’25",
499+ "description": "This profile was created in July of 2025."
500+ }
501+ ],
502+ "severity": "inform",
503+ "adultOnly": false,
504+ "identifier": "joined-jul-d",
505+ "defaultSetting": "warn"
506+ },
507+ {
508+ "blurs": "none",
509+ "locales": [
510+ {
511+ "lang": "en",
512+ "name": "Joined Aug ’25",
513+ "description": "This profile was created in August of 2025."
514+ }
515+ ],
516+ "severity": "inform",
517+ "adultOnly": false,
518+ "identifier": "joined-aug-d",
519+ "defaultSetting": "warn"
520+ },
521+ {
522+ "blurs": "none",
523+ "locales": [
524+ {
525+ "lang": "en",
526+ "name": "Joined Sep ’25",
527+ "description": "This profile was created in September of 2025."
528+ }
529+ ],
530+ "severity": "inform",
531+ "adultOnly": false,
532+ "identifier": "joined-sep-d",
533+ "defaultSetting": "warn"
534+ },
535+ {
536+ "blurs": "none",
537+ "locales": [
538+ {
539+ "lang": "en",
540+ "name": "Joined Oct ’25",
541+ "description": "This profile was created in October of 2025."
542+ }
543+ ],
544+ "severity": "inform",
545+ "adultOnly": false,
546+ "identifier": "joined-oct-d",
547+ "defaultSetting": "warn"
548+ },
549+ {
550+ "blurs": "none",
551+ "locales": [
552+ {
553+ "lang": "en",
554+ "name": "Joined Nov ’25",
555+ "description": "This profile was created in November of 2025."
556+ }
557+ ],
558+ "severity": "inform",
559+ "adultOnly": false,
560+ "identifier": "joined-nov-d",
561+ "defaultSetting": "warn"
562+ },
563+ {
564+ "blurs": "none",
565+ "locales": [
566+ {
567+ "lang": "en",
568+ "name": "Joined Dec ’25",
569+ "description": "This profile was created in December of 2025."
570+ }
571+ ],
572+ "severity": "inform",
573+ "adultOnly": false,
574+ "identifier": "joined-dec-d",
575+ "defaultSetting": "warn"
576+ }
577+ ]
578+ },
579+ "createdAt": "2025-02-10T06:08:05.000Z"
580+}
+14
src/bin/database.rs
···00000000000000
···1+//! Main entrypoint.
2+3+#[tokio::main(flavor = "current_thread")]
4+async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
5+ let subscriber = tracing_subscriber::fmt()
6+ .compact() // Use a more compact, abbreviated log format
7+ .with_file(true) // Display source code file paths
8+ .with_line_number(true) // Display source code line numbers
9+ .with_thread_ids(true) // Display the thread ID an event was recorded on
10+ .with_target(false) // Don't display the event's target (module path)
11+ .finish(); // Build the subscriber
12+ tracing::subscriber::set_global_default(subscriber)?;
13+ atproto_teq::database::main_database().await
14+}
+23
src/bin/jetstream.rs
···00000000000000000000000
···1+//! Main entrypoint.
2+3+#[tokio::main(flavor = "current_thread")]
4+async fn main() -> Result<(), Box<dyn std::error::Error>> {
5+ let subscriber = tracing_subscriber::fmt()
6+ .compact() // Use a more compact, abbreviated log format
7+ .with_file(true) // Display source code file paths
8+ .with_line_number(true) // Display source code line numbers
9+ .with_thread_ids(true) // Display the thread ID an event was recorded on
10+ .with_target(false) // Don't display the event's target (module path)
11+ .finish(); // Build the subscriber
12+ tracing::subscriber::set_global_default(subscriber)?;
13+ loop {
14+ if let Err(e) = atproto_teq::jetstream::main_jetstream().await {
15+ tracing::error!("Error in main_jetstream: {:?}", e);
16+ break;
17+ } else {
18+ tracing::info!("Restarting main_jetstream");
19+ tokio::time::sleep(tokio::time::Duration::from_secs(15)).await;
20+ }
21+ }
22+ Ok(())
23+}
+24
src/bin/negation.rs
···000000000000000000000000
···1+//! Main entrypoint.
2+3+use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool};
4+use std::str::FromStr;
5+6+#[tokio::main(flavor = "current_thread")]
7+async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
8+ let subscriber = tracing_subscriber::fmt()
9+ .compact() // Use a more compact, abbreviated log format
10+ .with_file(true) // Display source code file paths
11+ .with_line_number(true) // Display source code line numbers
12+ .with_thread_ids(true) // Display the thread ID an event was recorded on
13+ .with_target(false) // Don't display the event's target (module path)
14+ .finish(); // Build the subscriber
15+ tracing::subscriber::set_global_default(subscriber)?;
16+17+ const NEGATION_ID: &str = "did:plc:"; // TODO: Argumentize this.
18+ let mut agent = atproto_teq::webrequest::Agent::default();
19+ let pool_opts = SqliteConnectOptions::from_str("sqlite://prod.db").expect("Expected to be able to configure the database, but failed.")
20+ .journal_mode(SqliteJournalMode::Wal);
21+ let pool = SqlitePool::connect_with(pool_opts).await.expect("Expected to be able to connect to the database at sqlite://prod.db but failed.");
22+ atproto_teq::database::negation(NEGATION_ID, &mut agent, &pool).await?;
23+ Ok(())
24+}
+16
src/bin/webserve.rs
···0000000000000000
···1+//! Main entrypoint.
2+#![expect(let_underscore_drop)]
3+4+#[tokio::main(flavor = "current_thread")]
5+async fn main() -> Result<(), Box<dyn std::error::Error>> {
6+ let subscriber = tracing_subscriber::fmt()
7+ .compact() // Use a more compact, abbreviated log format
8+ .with_file(true) // Display source code file paths
9+ .with_line_number(true) // Display source code line numbers
10+ .with_thread_ids(true) // Display the thread ID an event was recorded on
11+ .with_target(false) // Don't display the event's target (module path)
12+ .finish(); // Build the subscriber
13+ tracing::subscriber::set_global_default(subscriber)?;
14+ let _ = tokio::spawn(atproto_teq::webserve::main_webserve()).await;
15+ Ok(())
16+}
···1+//! # Crypto
2+//! Sign messages using the secp256k1 elliptic curve algorithm.
3+4+use secp256k1::{Secp256k1, Message, SecretKey, PublicKey};
5+use secp256k1::hashes::{sha256, Hash};
6+use std::str::FromStr;
7+8+use crate::types::{AssignedLabelResponse, RetrievedLabelResponse, SignatureBytes, SignatureEnum};
9+10+11+/// Cryptographic signing and verification.
12+pub struct Crypto {
13+ private_key: SecretKey,
14+ _public_key: PublicKey,
15+}
16+impl Default for Crypto {
17+ fn default() -> Self {
18+ Self::new()
19+ }
20+}
21+impl Crypto {
22+ /// Create a new `Crypto` instance.
23+ pub fn new() -> Self {
24+ let secp = Secp256k1::new();
25+ drop(dotenvy::dotenv().expect("Failed to load .env file"));
26+ let private_key_hex = std::env::var("PRIVATE_KEY_HEX").expect("Expected to be able to get a private key from the environment, but failed");
27+ let private_key_vec = hex::decode(private_key_hex).expect("Expected to be able to decode a hex string, but failed");
28+ let private_key_array: [u8; 32] = private_key_vec.as_slice().try_into().expect("Expected 32 bytes, within curve order, but failed");
29+ let private_key = SecretKey::from_slice(&private_key_array).expect("Expected 32 bytes, within curve order, but failed");
30+ let public_key = PublicKey::from_secret_key(&secp, &private_key);
31+ Self {
32+ private_key,
33+ _public_key: public_key,
34+ }
35+ }
36+ /// Create a new `Crypto` instance from a slice.
37+ pub fn from_slice(slice: &[u8]) -> Self {
38+ let secp = Secp256k1::new();
39+ let private_key = SecretKey::from_slice(slice).expect("Expected 32 bytes, within curve order, but failed");
40+ let public_key = PublicKey::from_secret_key(&secp, &private_key);
41+ Self {
42+ private_key,
43+ _public_key: public_key,
44+ }
45+ }
46+ /// Sign a message.
47+ #[expect(clippy::cognitive_complexity)]
48+ pub fn sign(&self, label: &mut AssignedLabelResponse) {
49+ let secp = Secp256k1::new();
50+ let label_for_serialization = RetrievedLabelResponse {
51+ cts: label.cts.clone(),
52+ neg: label.neg,
53+ src: label.src.clone(),
54+ uri: label.uri.clone(),
55+ val: label.val.clone(),
56+ ver: label.ver,
57+ };
58+ tracing::debug!("Label for serialization: {:?}", label_for_serialization);
59+ label.sig = None;
60+ // let label_json = serde_json::to_string(&label_for_serialization).unwrap();
61+ // let digest = sha256::Hash::hash(msg.as_bytes());
62+ // let message = Message::from_digest(digest.to_byte_array());
63+ let label_cbor = serde_cbor::to_vec(&label_for_serialization).expect("Expected to be able to serialize a label, but failed");
64+ tracing::debug!("Label CBOR: {:?}", label_cbor);
65+ // decode the cbor we just made
66+ let label_decoded: RetrievedLabelResponse = serde_cbor::from_slice(&label_cbor).expect("Expected to be able to deserialize a label, but failed");
67+ tracing::debug!("Label decoded: {:?}", label_decoded);
68+ let digest = sha256::Hash::hash(&label_cbor);
69+ let message = Message::from_digest(digest.to_byte_array());
70+ let sig = secp.sign_ecdsa(&message, &self.private_key);
71+ // verify the sig we just made:
72+ let verified = secp.verify_ecdsa(&message, &sig, &self._public_key);
73+ assert!(verified.is_ok());
74+ tracing::debug!("Verified: {:?}", verified);
75+ tracing::debug!("Message: {:?}", message);
76+ tracing::debug!("Signature: {:?}", sig);
77+ tracing::debug!("Public key: {:?}", self._public_key);
78+ // let serialized_sig = sig.serialize_der();
79+ // return raw 64 byte sig not DER-encoded
80+ let serialized_sig: [u8; 64] = sig.serialize_compact();
81+ // serialized_sig.to_vec()
82+ label.sig = Some(SignatureEnum::Bytes(SignatureBytes::from_bytes(serialized_sig)));
83+ }
84+ /// Consume a label response and validate the signature.
85+ #[expect(clippy::cognitive_complexity)]
86+ pub fn validate(&self,
87+ label: RetrievedLabelResponse,
88+ sig: &str,
89+ public_key_string: &str, // multibase-encoded string
90+ ) -> bool {
91+ tracing::debug!("Retrieved label: {:?}", label);
92+ // let public_key_vec = hex::decode(public_key_string).unwrap();
93+ // When encoding public keys as strings, the preferred representation uses multibase (with base58btc specifically) and a multicode prefix to indicate the specific key type. By embedding metadata about the type of key in the encoding itself, they can be parsed unambiguously.
94+ // The process for encoding a public key in this format is:
95+ // Encode the public key curve "point" as bytes. Be sure to use the smaller "compact" or "compressed" representation.
96+ // Prepend the appropriate curve multicodec value, as varint-encoded bytes, in front of the key bytes
97+ // p256 (compressed, 33 byte key length): p256-pub, code 0x1200, varint-encoded bytes: [0x80, 0x24]
98+ // k256 (compressed, 33 byte key length): secp256k1-pub, code 0xE7, varint bytes: [0xE7, 0x01]
99+ // Encode the combined bytes with with base58btc, and prefix with a z character, yielding a multibase-encoded string
100+ // The decoding process is the same in reverse, using the identified curve type as context.
101+ let public_key_string = public_key_string.strip_prefix("z").expect("Expected to be able to strip a prefix, but failed");
102+ let public_key_vec = bs58::decode(public_key_string).into_vec().expect("Expected to be able to decode a base58 string, but failed");
103+ // // Remove the multicodec prefix
104+ // let public_key_vec = public_key_vec[2..].to_vec();
105+ // Determine which curve the key is for
106+ match public_key_vec[0] {
107+ 0x80 => {
108+ tracing::debug!("p256");
109+ // p256
110+ },
111+ 0xE7 => {
112+ tracing::debug!("k256");
113+ // k256
114+ },
115+ _ => {
116+ panic!("Unknown curve");
117+ },
118+ };
119+ let public_key_vec = public_key_vec[2..].to_vec();
120+121+ let public_key_array: [u8; 33] = public_key_vec.as_slice().try_into().expect("Expected 33 bytes, within curve order, but failed");
122+ let public_key = PublicKey::from_slice(&public_key_array).expect("Expected 33 bytes, within curve order, but failed");
123+ // use of the "low-S" signature variant is required
124+ // let secp = Secp256k1::new();
125+ let secp = Secp256k1::verification_only();
126+ // let label_json = serde_json::to_string(&label).unwrap();
127+ // tracing::debug!("Label JSON: {:?}", label_json);
128+ let label_cbor = serde_cbor::to_vec(&label).expect("Expected to be able to serialize a label, but failed");
129+ let digest = sha256::Hash::hash(&label_cbor);
130+ tracing::debug!("Digest: {:?}", digest);
131+ let message = Message::from_digest(digest.to_byte_array());
132+ tracing::debug!("Signature: {:?}", sig);
133+ let sig = SignatureBytes::from_str(sig).expect("Expected to be able to parse a signature from a string, but failed");
134+ tracing::debug!("Signature bytes: {:?}", sig);
135+ let signature = secp256k1::ecdsa::Signature::from_compact(&sig.as_vec()).expect("Expected to be able to parse a signature from a byte array, but failed");
136+ tracing::debug!("Message: {:?}", message);
137+ tracing::debug!("Signature: {:?}", signature);
138+ tracing::debug!("Public key: {:?}", public_key);
139+ secp.verify_ecdsa(&message, &signature, &public_key).is_ok()
140+ }
141+142+}
···1+//! Structs, enums, and impls.
2+use std::str::FromStr;
3+use base64::Engine;
4+use chrono::{DateTime as Datetime, Datelike};
5+use jetstream_oxide::exports::Did;
6+use serde::{ser::{Serialize, Serializer}, Deserialize};
7+8+// /// How should a client visually convey this label?
9+// enum LabelDefinitionSeverity {
10+// /// 'inform' means neutral and informational
11+// Inform,
12+// /// 'alert' means negative and warning
13+// Alert,
14+// /// 'none' means show nothing.
15+// None,
16+// }
17+// impl LabelDefinitionSeverity {
18+// fn to_string(&self) -> String {
19+// match self {
20+// Self::Inform => "inform".to_owned(),
21+// Self::Alert => "alert".to_owned(),
22+// Self::None => "none".to_owned(),
23+// }
24+// }
25+// }
26+// /// What should this label hide in the UI, if applied?
27+// enum LabelDefinitionBlurs {
28+// /// 'content' hides all of the target
29+// Content,
30+// /// 'media' hides the images/video/audio
31+// Media,
32+// /// 'none' hides nothing.
33+// None,
34+// }
35+// impl LabelDefinitionBlurs {
36+// fn to_string(&self) -> String {
37+// match self {
38+// Self::Content => "content".to_owned(),
39+// Self::Media => "media".to_owned(),
40+// Self::None => "none".to_owned(),
41+// }
42+// }
43+// }
44+// /// The default setting for this label.
45+// enum LabelDefinitionDefaultSetting {
46+// Hide,
47+// Warn,
48+// Ignore,
49+// }
50+// impl LabelDefinitionDefaultSetting {
51+// fn to_string(&self) -> String {
52+// match self {
53+// Self::Hide => "hide".to_owned(),
54+// Self::Warn => "warn".to_owned(),
55+// Self::Ignore => "ignore".to_owned(),
56+// }
57+// }
58+// }
59+// /// Strings which describe the label in the UI, localized into a specific language.
60+// struct LabelValueDefinitionStrings {
61+// /// The code of the language these strings are written in.
62+// lang: String,
63+// /// A short human-readable name for the label.
64+// name: String,
65+// /// A longer description of what the label means and why it might be applied.
66+// description: String,
67+// }
68+// /// Labels.
69+// struct LabelDefinition {
70+// /// The value of the label being defined. Must only include lowercase ascii and the '-' character (a-z-+).
71+// identifier: String,
72+// /// How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.
73+// severity: LabelDefinitionSeverity,
74+// /// What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.
75+// blurs: LabelDefinitionBlurs,
76+// /// The default setting for this label.
77+// default_setting: LabelDefinitionDefaultSetting,
78+// /// Does the user need to have adult content enabled in order to configure this label?
79+// adult_content: Option<bool>,
80+// /// Strings which describe the label in the UI, localized into a specific language.
81+// locales: Vec<LabelValueDefinitionStrings>,
82+// }
83+// impl LabelDefinition {
84+// fn new(identifier: String) -> Self {
85+// let locales = vec![LabelValueDefinitionStrings {
86+// lang: "en".to_owned(),
87+// name: identifier.replace("joined-", "Joined "),
88+// description: format!("Profile created {}", identifier.replace("joined-", "").replace("-", " ")),
89+// }];
90+// Self {
91+// identifier,
92+// severity: LabelDefinitionSeverity::Inform,
93+// blurs: LabelDefinitionBlurs::None,
94+// default_setting: LabelDefinitionDefaultSetting::Warn,
95+// adult_content: Some(false),
96+// locales,
97+// }
98+// }
99+// }
100+101+102+#[derive(Debug)]
103+/// Signature bytes.
104+pub struct SignatureBytes([u8; 64]);
105+impl FromStr for SignatureBytes {
106+ type Err = std::io::Error;
107+ fn from_str(s: &str) -> Result<Self, Self::Err> {
108+ let bytes = base64::engine::GeneralPurpose::new(
109+ &base64::alphabet::STANDARD,
110+ base64::engine::general_purpose::NO_PAD).decode(s).expect("Expected to be able to decode the base64 string as bytes but failed.");
111+ let mut array = [0; 64];
112+ array.copy_from_slice(&bytes);
113+ Ok(Self(array))
114+ }
115+}
116+impl SignatureBytes {
117+ /// Create a new signature from a vector of bytes.
118+ pub fn from_vec(vec: Vec<u8>) -> Self {
119+ let mut array = [0; 64];
120+ array.copy_from_slice(&vec);
121+ Self(array)
122+ }
123+ /// Create a new signature from a slice of bytes.
124+ pub const fn from_bytes(bytes: [u8; 64]) -> Self {
125+ Self(bytes)
126+ }
127+ /// Create a new signature from a JSON value in the format of a $bytes object.
128+ pub fn from_json(json: serde_json::Value) -> Self {
129+ let byte_string = json["$bytes"].as_str().expect("Expected to be able to get the $bytes field from the JSON object as a string but failed.");
130+ let bytes = base64::engine::GeneralPurpose::new(
131+ &base64::alphabet::STANDARD,
132+ base64::engine::general_purpose::NO_PAD).decode(byte_string).expect("Expected to be able to decode the base64 string as bytes but failed.");
133+ Self::from_vec(bytes)
134+ }
135+ /// Get the signature as a vector of bytes.
136+ pub fn as_vec(&self) -> Vec<u8> {
137+ self.0.to_vec()
138+ }
139+ /// Get the signature as a base64 string.
140+ pub fn as_base64(&self) -> String {
141+ base64::engine::GeneralPurpose::new(
142+ &base64::alphabet::STANDARD,
143+ base64::engine::general_purpose::NO_PAD).encode(self.0)
144+ }
145+ /// Get the signature as a JSON object in the format of a $bytes object.
146+ pub fn as_json_object(&self) -> serde_json::Value {
147+ serde_json::json!({
148+ "$bytes": self.as_base64()
149+ })
150+ }
151+}
152+impl Serialize for SignatureBytes {
153+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
154+ where
155+ S: Serializer,
156+ {
157+ serializer.serialize_bytes(&self.0)
158+ }
159+}
160+#[derive(Debug)]
161+/// Signature bytes or JSON value.
162+pub enum SignatureEnum {
163+ /// Signature bytes.
164+ Bytes(SignatureBytes),
165+ /// Signature JSON value.
166+ Json(serde_json::Value),
167+}
168+impl Serialize for SignatureEnum {
169+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
170+ where
171+ S: Serializer,
172+ {
173+ match self {
174+ Self::Bytes(bytes) => bytes.serialize(serializer),
175+ Self::Json(json) => json.serialize(serializer),
176+ }
177+ }
178+}
179+180+#[derive(serde::Serialize, Debug)]
181+/// Label response content.
182+pub struct AssignedLabelResponse {
183+ // /// Timestamp at which this label expires (no longer applies, is no longer valid).
184+ // exp: Option<DateTime<FixedOffset>>,
185+ // /// Optionally, CID specifying the specific version of 'uri' resource this label applies to. \
186+ // /// If provided, the label applies to a specific version of the subject uri
187+ // cid: Option<String>,
188+ /// Timestamp when this label was created.
189+ /// Note that timestamps in a distributed system are not trustworthy or verified by default.
190+ pub cts: String, // DateTime<Utc>,
191+ /// If true, this is a negation label, indicates that this label "negates" an earlier label with the same src, uri, and val.
192+ /// If the neg field is false, best practice is to simply not include the field at all.
193+ #[serde(skip_serializing_if = "bool_is_false")]
194+ pub neg: bool,
195+ /// Signature of dag-cbor encoded label. \
196+ /// cryptographic signature bytes. \
197+ /// Uses the bytes type from the [Data Model](https://atproto.com/specs/data-model), which encodes in JSON as a $bytes object with base64 encoding
198+ /// When labels are being transferred as full objects between services, the ver and sig fields are required.
199+ pub sig: Option<SignatureEnum>,
200+ /// DID of the actor authority (account) which generated this label. \
201+ pub src: Did,
202+ /// AT URI of the record, repository (account), or other resource that this label applies to. \
203+ /// For a specific record, an `at://` URI. For an account, the `did:`.
204+ pub uri: String,
205+ /// The short (<=128 character) string name of the value or type of this label.
206+ pub val: String,
207+ /// The AT Protocol version of the label object schema version. \
208+ /// Current version is always 1.
209+ /// When labels are being transferred as full objects between services, the ver and sig fields are required.
210+ pub ver: u64,
211+}
212+impl AssignedLabelResponse {
213+ /// Create a new label.
214+ pub fn generate(
215+ src: Did,
216+ uri: String,
217+ val: String,
218+ ) -> Self {
219+ let sig = SignatureEnum::Bytes(SignatureBytes([0; 64]));
220+ Self::reconstruct(src, uri, val, false, chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), sig)
221+ }
222+ /// Reconstruct a label from parts.
223+ pub const fn reconstruct(
224+ src: Did,
225+ uri: String,
226+ val: String,
227+ neg: bool,
228+ cts: String,
229+ sig: SignatureEnum,
230+ ) -> Self {
231+ Self {
232+ ver: 1,
233+ src,
234+ uri,
235+ // cid: None,
236+ val,
237+ neg,
238+ sig: Some(sig),
239+ cts,
240+ // exp: None,
241+ }
242+ }
243+ // The process to sign or verify a signature is to construct a complete version of the label, using only the specified schema fields, and not including the sig field.
244+ // This means including the ver field, but not any $type field or other un-specified fields which may have been included in a Lexicon representation of the label. This data object is then encoded in CBOR, following the deterministic IPLD/DAG-CBOR normalization rules.
245+ // The CBOR bytes are hashed with SHA-256, and then the direct hash bytes (not a hex-encoded string) are signed (or verified) using the appropriate cryptographic key. The signature bytes are stored in the sig field as bytes (see Data Model for details representing bytes).
246+ /// Generate signature.
247+ pub fn sign(mut self) -> Self {
248+ crate::crypto::Crypto::new().sign(&mut self);
249+ self
250+ }
251+252+}
253+#[derive(serde::Serialize)]
254+/// A label response wrapper.
255+pub struct AssignedLabelResponseWrapper {
256+ /// The cursor to find the sequence number of this label. \
257+ /// Returned as a string.
258+ pub cursor: String,
259+ /// Vector of labels.
260+ pub labels: Vec<AssignedLabelResponse>,
261+}
262+#[derive(serde::Serialize, Debug)]
263+/// A label response wrapper.
264+pub struct SubscribeLabelsLabels {
265+ /// The sequence number of this label. \
266+ /// The seq field is a monotonically increasing integer, starting at 1 for the first label.
267+ /// Returned as a long.
268+ pub seq: i64,
269+ /// Vector of labels.
270+ pub labels: Vec<AssignedLabelResponse>,
271+}
272+#[derive(Deserialize)]
273+#[expect(non_snake_case, reason = "Name matches URI parameter literally.")]
274+/// URI parameters.
275+pub struct UriParams {
276+ /// URI patterns.
277+ pub uriPatterns: Option<String>,
278+ /// The DID of sources.
279+ pub sources: Option<String>,
280+ /// The limit of labels to fetch. Default is (50?).
281+ pub limit: Option<i64>,
282+ /// The cursor to use for seq.
283+ pub cursor: Option<String>,
284+ /// The actor to lookup.
285+ pub actor: Option<String>,
286+}
287+const fn neg_default() -> bool {
288+ false
289+}
290+291+#[derive(serde::Serialize, serde::Deserialize, Debug)]
292+/// A label retrieved from the atproto API.
293+pub struct RetrievedLabelResponse {
294+ /// The creation timestamp.
295+ pub cts: String,
296+ /// Whether the label is negative.
297+ #[serde(skip_serializing_if = "bool_is_false", default = "neg_default")]
298+ pub neg: bool,
299+ /// The source DID.
300+ pub src: Did,
301+ /// The URI.
302+ pub uri: String,
303+ /// The value.
304+ pub val: String,
305+ /// The version.
306+ pub ver: u64,
307+}
308+#[derive(serde::Serialize, serde::Deserialize, Debug)]
309+/// A label retrieved from the atproto API.
310+pub struct SignedRetrievedLabelResponse {
311+ /// The creation timestamp.
312+ pub cts: String,
313+ /// Whether the label is negative.
314+ #[serde(skip_serializing_if = "bool_is_false")]
315+ pub neg: bool,
316+ /// The source DID.
317+ pub sig: serde_json::Value,
318+ /// The source DID.
319+ pub src: Did,
320+ /// The URI.
321+ pub uri: String,
322+ /// The value.
323+ pub val: String,
324+ /// The version.
325+ pub ver: u64,
326+}
327+fn bool_is_false(b: &bool) -> bool {
328+ !b
329+}
330+#[derive(serde::Serialize, serde::Deserialize, Debug)]
331+/// A label retrieved from the atproto API.
332+pub struct SignedRetrievedLabelResponseWs {
333+ /// The creation timestamp.
334+ pub cts: String,
335+ /// Whether the label is negative.
336+ #[serde(skip_serializing_if = "bool_is_false")]
337+ pub neg: bool,
338+ /// The signature.
339+ #[serde(with = "serde_bytes")]
340+ pub sig: [u8; 64],
341+ /// The source DID.
342+ pub src: Did,
343+ /// The URI.
344+ pub uri: String,
345+ /// The value.
346+ pub val: String,
347+ /// The version.
348+ pub ver: u64,
349+}
350+#[derive(serde::Serialize, serde::Deserialize, Debug)]
351+/// Labels with a sequence number.
352+pub struct LabelsVecWithSeq {
353+ /// The sequence number.
354+ pub seq: u64,
355+ /// The labels.
356+ pub labels: Vec<SignedRetrievedLabelResponseWs>,
357+}
358+#[derive(Debug)]
359+/// Profile stats.
360+pub struct ProfileStats {
361+ /// The number of followers.
362+ pub follower_count: i32,
363+ /// The number of posts.
364+ pub post_count: i32,
365+ /// The creation timestamp, as reported by actor.
366+ pub created_at: Datetime<chrono::Utc>,
367+ /// The timestamp at which the stats were checked.
368+ pub checked_at: Datetime<chrono::Utc>,
369+}
370+impl ProfileStats {
371+ fn new(
372+ follower_count: i32,
373+ post_count: i32,
374+ created_at: Datetime<chrono::Utc>,
375+ ) -> Self {
376+ Self {
377+ follower_count,
378+ post_count,
379+ created_at,
380+ checked_at: chrono::Utc::now(),
381+ }
382+ }
383+ /// Given a AT uri, lookup the profile and return the stats.
384+ pub async fn from_at_url(
385+ uri: String,
386+ agent: &mut crate::webrequest::Agent,
387+ ) -> Result<Self, Box<dyn std::error::Error>> {
388+ let uri = uri.replace("at://","").replace("/app.bsky.actor.profile/self", "");
389+ if let Ok(profile) = agent.get_profile(uri.as_str()).await {
390+ tracing::debug!("{:?}", profile);
391+392+ // Begin enforce reasonable limits on the number of follows.
393+ // https://jazco.dev/2025/02/19/imperfection/
394+ let follows_count = profile["followsCount"].as_i64().expect("Expected to be able to parse an integer, but failed") as i32;
395+ const MAX_FOLLOWS: i32 = 4_000;
396+ if follows_count > MAX_FOLLOWS {
397+ tracing::warn!("Profile {:?} has a suspicious number of follows: {:?}", uri, follows_count);
398+ return Err(Box::new(std::io::Error::new(
399+ std::io::ErrorKind::Other,
400+ "Profile has a suspicious number of follows",
401+ )));
402+ }
403+ // End
404+405+ let followers_count = profile["followersCount"].as_i64().expect("Expected to be able to parse an integer, but failed") as i32;
406+ let posts_count = profile["postsCount"].as_i64().expect("Expected to be able to parse an integer, but failed") as i32;
407+ let created_at = Datetime::parse_from_rfc3339(profile["createdAt"].as_str().expect("Expected to be able to parse a string, but failed"))?;
408+ Ok(Self::new(followers_count, posts_count, created_at.into()))
409+ } else {
410+ Err(Box::new(std::io::Error::new(
411+ std::io::ErrorKind::Other,
412+ "Failed to get profile",
413+ )))
414+ }
415+ }
416+}
417+#[derive(Debug, Clone, Copy, serde::Deserialize)]
418+enum Year {
419+ _2022,
420+ _2023,
421+ _2024,
422+ _2025,
423+}
424+impl std::fmt::Display for Year {
425+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
426+ write!(
427+ f,
428+ "{}",
429+ match self {
430+ Self::_2022 => "a",
431+ Self::_2023 => "b",
432+ Self::_2024 => "c",
433+ Self::_2025 => "d",
434+ }
435+ )
436+ }
437+}
438+impl Serialize for Year {
439+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
440+ where
441+ S: Serializer,
442+ {
443+ let val: char = self.to_string().chars().next().expect("Expected to be able to get the first character, but failed");
444+ serializer.serialize_char(val)
445+ }
446+}
447+#[derive(Debug, Clone, Copy, serde::Deserialize)]
448+enum Month {
449+ January,
450+ February,
451+ March,
452+ April,
453+ May,
454+ June,
455+ July,
456+ August,
457+ September,
458+ October,
459+ November,
460+ December,
461+}
462+impl Serialize for Month {
463+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
464+ where
465+ S: Serializer,
466+ {
467+ let val = self.to_string().to_lowercase();
468+ serializer.serialize_str(&val)
469+ }
470+}
471+impl std::fmt::Display for Month {
472+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
473+ write!(
474+ f,
475+ "{}",
476+ match self {
477+ Self::January => "jan",
478+ Self::February => "feb",
479+ Self::March => "mar",
480+ Self::April => "apr",
481+ Self::May => "may",
482+ Self::June => "jun",
483+ Self::July => "jul",
484+ Self::August => "aug",
485+ Self::September => "sep",
486+ Self::October => "oct",
487+ Self::November => "nov",
488+ Self::December => "dec",
489+ }
490+ )
491+ }
492+}
493+/// Profile labels for month+year.
494+#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize)]
495+pub struct ProfileLabel {
496+ year: Year,
497+ month: Month,
498+}
499+impl ProfileLabel {
500+ /// Create a new profile label from a datetime.
501+ #[allow(clippy::cognitive_complexity)]
502+ pub fn from_datetime(datetime: Datetime<chrono::Utc>) -> Option<Self> {
503+ Some(Self {
504+ year: match datetime.year() {
505+ 2023 => Year::_2023,
506+ 2024 => Year::_2024,
507+ 2025 => Year::_2025,
508+ _ => {
509+ tracing::debug!("Invalid year");
510+ return None;
511+ }
512+ },
513+ month: match datetime.month() {
514+ 1 => Month::January,
515+ 2 => Month::February,
516+ 3 => Month::March,
517+ 4 => Month::April,
518+ 5 => Month::May,
519+ 6 => Month::June,
520+ 7 => Month::July,
521+ 8 => Month::August,
522+ 9 => Month::September,
523+ 10 => Month::October,
524+ 11 => Month::November,
525+ 12 => Month::December,
526+ _ => {
527+ tracing::debug!("Invalid month");
528+ return None;
529+ }
530+ },
531+ })
532+ }
533+ /// Convert a profile label to a string.
534+ pub fn to_label_val(self) -> String {
535+ format!("joined-{}-{}", self.month, self.year)
536+ .to_lowercase()
537+ }
538+}
539+/// Profile with optional stats and label.
540+#[derive(Debug)]
541+pub struct Profile {
542+ did: String,
543+ /// Stats for a profile.
544+ pub stats: Option<ProfileStats>,
545+ label: Option<String>,
546+}
547+impl Profile {
548+ /// Create a new profile, given a DID.
549+ pub fn new(did: &str) -> Self {
550+ Self {
551+ did: {
552+ // if did.starts_with("did:") {
553+ // format!("at://{}{}", did, "/app.bsky.actor.profile/self")
554+ // } else {
555+ did.to_owned()
556+ // }
557+ },
558+ stats: None,
559+ label: None,
560+ }
561+ }
562+ /// Fetch stats for a profile.
563+ pub async fn determine_stats(&mut self, agent: &mut crate::webrequest::Agent, pool: &sqlx::sqlite::SqlitePool) -> &mut Self {
564+ if let Ok(stats) = ProfileStats::from_at_url(self.did.clone(), agent).await {
565+ self.stats = Some(stats);
566+ } else {
567+ tracing::warn!("Failed to get stats for profile {}", self.did);
568+ }
569+ self.insert_profile_stats(pool).await.expect("Expected to be able to insert profile stats, but failed");
570+ self
571+ }
572+ /// Determine if stats exist for a profile.
573+ pub async fn determine_stats_exist(&mut self, pool: &sqlx::Pool<sqlx::Sqlite>) -> Result<Option<&mut Self>, Box<dyn std::error::Error + Sync + Send>> {
574+ if self.stats.is_some() {
575+ return Ok(Some(self));
576+ }
577+ let profile_stats = sqlx::query!(
578+ r#"
579+ SELECT created_at "created_at: String", follower_count, post_count, checked_at "checked_at: String" FROM profile_stats WHERE did = ?
580+ "#,
581+ self.did
582+ )
583+ .fetch_one(pool)
584+ .await;
585+ if profile_stats.is_ok() {
586+ let profile_stats = profile_stats.expect("Expected to be able to unwrap a profile_checked_at, but failed");
587+ let created_at = Datetime::parse_from_rfc3339(profile_stats.created_at.as_str()).expect("Expected to be able to parse a string as a datetime, but failed").to_utc();
588+ let follower_count = profile_stats.follower_count as i32;
589+ let post_count = profile_stats.post_count as i32;
590+ let checked_at = Datetime::parse_from_rfc3339(profile_stats.checked_at.as_str()).expect("Expected to be able to parse a string as a datetime, but failed").to_utc();
591+ const TIMEPERIOD: i64 = 60 * 60 * 24 * 7 * 1000 * 4; // 4 weeks in milliseconds
592+ if chrono::Utc::now().timestamp_millis() - checked_at.timestamp_millis() < TIMEPERIOD {
593+ tracing::debug!("Stats exist for: {:?}", self.did);
594+ self.stats = Some(ProfileStats {
595+ follower_count,
596+ post_count,
597+ created_at,
598+ checked_at,
599+ });
600+ return Ok(Some(self));
601+ }
602+ tracing::info!("Refetching stats for: {:?}", self.did);
603+ return Ok(None);
604+ }
605+ tracing::info!("Stats do not exist for: {:?}", self.did);
606+ Ok(None)
607+ }
608+ /// Determine the label of a profile.
609+ pub async fn determine_label(&mut self, pool: &sqlx::sqlite::SqlitePool) -> &mut Self {
610+ if self.stats.is_none() {
611+ return self;
612+ }
613+ const MIN_POSTS: i32 = 30;
614+ const SOME_POSTS: i32 = 200;
615+ const MIN_FOLLOWERS: i32 = 400;
616+ const SOME_FOLLOWERS: i32 = 2_500;
617+ let post_count = self.stats.as_ref().expect("Expected stats to exist, but failed").post_count;
618+ let follower_count = self.stats.as_ref().expect("Expected stats to exist, but failed").follower_count;
619+ if (post_count >= MIN_POSTS && follower_count >= MIN_FOLLOWERS) && (post_count >= SOME_POSTS || follower_count >= SOME_FOLLOWERS)
620+ {
621+ match ProfileLabel::from_datetime(self.stats.as_ref().expect("Expected stats to exist, but failed").created_at) {
622+ Some(label) => self.label = Some(label.to_label_val()),
623+ None => {
624+ tracing::debug!("Invalid datetime");
625+ }
626+ }
627+ }
628+ self.insert_profile_labels(pool).await.expect("Expected to be able to insert profile labels, but failed");
629+ self
630+ }
631+ /// Determine the label of a profile, and insert it without checking stats reqs.
632+ pub async fn determine_label_agnostic(&mut self, pool: &sqlx::sqlite::SqlitePool) -> &mut Self {
633+ if self.stats.is_none() {
634+ return self;
635+ }
636+ match ProfileLabel::from_datetime(self.stats.as_ref().expect("Expected stats to exist, but failed").created_at) {
637+ Some(label) => self.label = Some(label.to_label_val()),
638+ None => {
639+ tracing::debug!("Invalid datetime");
640+ }
641+ }
642+ self.insert_profile_labels(pool).await.expect("Expected to be able to insert profile labels, but failed");
643+ self
644+ }
645+ /// Insert a profile into the database.
646+ pub async fn insert_profile(self, pool: &sqlx::sqlite::SqlitePool) -> Result<Self, sqlx::Error> {
647+ if (sqlx::query(&format!(
648+ "INSERT INTO profile (did) VALUES ('{}')",
649+ self.did
650+ ))
651+ .execute(pool)
652+ .await).is_ok() {
653+ tracing::debug!("Inserted profile {:?}", self.did);
654+ } else {
655+ tracing::debug!("Duplicate profile: {:?}", self.did);
656+ }
657+ Ok(self)
658+ }
659+ /// Insert profile stats into the database.
660+ pub async fn insert_profile_stats(
661+ &self,
662+ pool: &sqlx::sqlite::SqlitePool,
663+ ) -> Result<(), sqlx::Error> {
664+ if self.stats.is_none() {
665+ return Ok(());
666+ }
667+ // if sqlx::query!(
668+ // r#"SELECT did "did: String" FROM profile_stats WHERE did = ? LIMIT 1"#,
669+ // self.did
670+ // )
671+ // .fetch_one(pool)
672+ // .await.is_ok() {
673+ // tracing::debug!("Stats already exist for {:?}", self.did);
674+ // return Ok(());
675+ // }
676+ let created_at = self.stats.as_ref().expect("Expected stats to exist, but failed").created_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
677+ let follower_count = self.stats.as_ref().expect("Expected stats to exist, but failed").follower_count;
678+ let post_count = self.stats.as_ref().expect("Expected stats to exist, but failed").post_count;
679+ let checked_at = self.stats.as_ref().expect("Expected stats to exist, but failed").checked_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
680+ _ = sqlx::query!(r#"
681+ INSERT INTO profile_stats (did, created_at, follower_count, post_count, checked_at)
682+ VALUES (?, ?, ?, ?, ?)
683+ ON CONFLICT(did) DO UPDATE SET
684+ created_at = ?,
685+ follower_count = ?,
686+ post_count = ?,
687+ checked_at = ?
688+ "#,
689+ self.did,
690+ created_at,
691+ follower_count,
692+ post_count,
693+ checked_at,
694+ created_at,
695+ follower_count,
696+ post_count,
697+ checked_at,
698+ )
699+ .execute(pool).await.expect("Expected to be able to insert profile stats, but failed");
700+ tracing::info!("Inserted profile stats for {:?} with {:?} followers", self.did, self.stats.as_ref().expect("Expected stats to exist, but failed").follower_count);
701+ Ok(())
702+ }
703+ /// Negate a profile label.
704+ pub async fn negate_label(
705+ &mut self,
706+ pool: &sqlx::sqlite::SqlitePool,
707+ ) -> Result<(), sqlx::Error> {
708+ if self.stats.is_none() {
709+ tracing::warn!("No stats for {:?}", self.did);
710+ return Ok(());
711+ }
712+ match ProfileLabel::from_datetime(self.stats.as_ref().expect("Expected stats to exist, but failed").created_at) {
713+ Some(label) => self.label = Some(label.to_label_val()),
714+ None => {
715+ tracing::debug!("Invalid datetime");
716+ }
717+ }
718+ let label = self.label.as_ref().expect("Expected label to exist, but failed");
719+ let uri = self.did.as_str();
720+ let val: &str = label.as_str();
721+ drop(dotenvy::dotenv().expect("Failed to load .env file"));
722+ let self_did = dotenvy::var("SELF_DID").expect("Expected to be able to get the SELF_DID from the environment, but failed");
723+ let src = Did::new(self_did).expect("Expected to be able to create a valid DID but failed");
724+ let mut label_response: AssignedLabelResponse = AssignedLabelResponse::generate(src, self.did.clone(), val.to_owned());
725+ label_response.neg = true;
726+ label_response = label_response.sign();
727+ let sig_enum = label_response.sig.expect("Expected a signature, but failed");
728+ if let SignatureEnum::Bytes(sig) = sig_enum {
729+ let sig = sig.as_vec();
730+ _ = sqlx::query!(
731+ r#"INSERT INTO profile_labels (uri, val, neg, cts, sig) VALUES (?, ?, ?, ?, ?)"#,
732+ uri,
733+ val,
734+ label_response.neg,
735+ label_response.cts,
736+ sig,
737+ )
738+ .execute(pool)
739+ .await?;
740+ }
741+ tracing::info!("Negated profile label for {:?} with {:?}", self.did, self.label.as_ref().expect("Expected label to exist, but failed"));
742+ Ok(())
743+ }
744+ /// Insert profile labels into the database.
745+ async fn insert_profile_labels(
746+ &self,
747+ pool: &sqlx::sqlite::SqlitePool,
748+ ) -> Result<(), sqlx::Error> {
749+ if self.label.is_none() {
750+ return Ok(());
751+ }
752+ if sqlx::query!(
753+ r#"SELECT seq FROM profile_labels WHERE uri = ? LIMIT 1"#,
754+ self.did
755+ )
756+ .fetch_one(pool)
757+ .await.is_ok() {
758+ tracing::debug!("Label already exists for {:?}", self.did);
759+ return Ok(());
760+ }
761+ let label = self.label.as_ref().expect("Expected label to exist, but failed");
762+ let uri = self.did.as_str();
763+ let val: &str = label.as_str();
764+ drop(dotenvy::dotenv().expect("Failed to load .env file"));
765+ let self_did = dotenvy::var("SELF_DID").expect("Expected to be able to get the SELF_DID from the environment, but failed");
766+ let src = Did::new(self_did).expect("Expected to be able to create a valid DID but failed");
767+ let mut label_response: AssignedLabelResponse = AssignedLabelResponse::generate(src, self.did.clone(), val.to_owned());
768+ label_response = label_response.sign();
769+ let sig_enum = label_response.sig.expect("Expected a signature, but failed");
770+ if let SignatureEnum::Bytes(sig) = sig_enum {
771+ let sig = sig.as_vec();
772+ let result = sqlx::query!(
773+ r#"INSERT INTO profile_labels (uri, val, cts, sig) VALUES (?, ?, ?, ?)"#,
774+ uri,
775+ val,
776+ label_response.cts,
777+ sig,
778+ )
779+ .execute(pool)
780+ .await;
781+ if result.is_ok() {
782+ tracing::info!("Inserted profile label for {:?} with {:?}", self.did, self.label.as_ref().expect("Expected label to exist, but failed"));
783+ } else {
784+ tracing::debug!("Duplicate profile label for {:?}", self.did);
785+ }
786+ return Ok(());
787+ }
788+ tracing::warn!("Failed to insert profile label for {:?}", self.did);
789+ Ok(())
790+ }
791+ /// Remove label from profile_labels.
792+ /// Used when a label needs to be regenerated.
793+ pub async fn remove_label(
794+ pool: &sqlx::sqlite::SqlitePool,
795+ seq: i64,
796+ ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
797+ tracing::debug!("Removing label with seq: {:?}", seq);
798+ _ = sqlx::query!(
799+ r#"DELETE FROM profile_labels WHERE seq = ?"#,
800+ seq,
801+ )
802+ .execute(pool)
803+ .await.expect("Expected to be able to delete a label, but failed.");
804+ Ok(())
805+ }
806+}
···1+//! Requests to the atproto API.
2+use std::env;
3+use std::fs;
4+use std::sync::Arc;
5+6+use base64::Engine;
7+use reqwest::Client;
8+use tokio::sync::Mutex;
9+10+use crate::types::{LabelsVecWithSeq, RetrievedLabelResponse, SignatureBytes};
11+enum ApiEndpoint {
12+ Authorized,
13+ Public,
14+}
15+/// Agent for interactions with atproto.
16+#[derive(Clone)]
17+pub struct Agent {
18+ /// The access JWT.
19+ pub access_jwt: Arc<Mutex<String>>,
20+ /// The refresh JWT.
21+ pub refresh_jwt: Arc<Mutex<String>>,
22+ /// The reqwest client.
23+ pub client: Client,
24+ /// The DID of the labeler.
25+ pub self_did: Arc<String>,
26+}
27+impl Default for Agent {
28+ fn default() -> Self {
29+ drop(dotenvy::dotenv().expect("Failed to load .env file"));
30+ Self {
31+ access_jwt: Arc::new(Mutex::new(env::var("ACCESS_JWT").expect("ACCESS_JWT must be set"))),
32+ refresh_jwt: Arc::new(Mutex::new(env::var("REFRESH_JWT").expect("REFRESH_JWT must be set"))),
33+ client: Client::new(),
34+ self_did: Arc::new(env::var("SELF_DID").expect("SELF_DID must be set")),
35+ }
36+ }
37+}
38+impl Agent {
39+ /// The base URL of the atproto API's XRPC endpoint.
40+ /// Rate limit: 3_000 per 5 minutes
41+ const AUTH_URL: &'static str = "https://bsky.social/xrpc/";
42+ const PUBLIC_URL: &'static str = "https://public.api.bsky.app/xrpc/";
43+ async fn client_get(
44+ &self,
45+ path: &str,
46+ parameters: &[(&str, &str)],
47+ api_endpoint: &ApiEndpoint,
48+ ) -> reqwest::Response {
49+ self.client
50+ .get(format!("{}{}", match api_endpoint {
51+ ApiEndpoint::Authorized => Self::AUTH_URL,
52+ ApiEndpoint::Public => Self::PUBLIC_URL,
53+ }, &path))
54+ .header("Content-Type", "application/json")
55+ .header("Authorization", format!("Bearer {}", self.access_jwt.lock().await))
56+ .header("atproto-accept-labelers", self.self_did.as_str())
57+ .query(parameters)
58+ .send()
59+ .await.expect("Expected to be able to send request, but failed.")
60+ }
61+ async fn client_refresh(&self) {
62+ tracing::warn!("Token expired, refreshing");
63+ let response = self.client
64+ .post(format!(
65+ "{}{}",
66+ Self::AUTH_URL,
67+ "com.atproto.server.refreshSession"
68+ ))
69+ .header("Content-Type", "application/json")
70+ .header("Authorization", format!("Bearer {}", self.refresh_jwt.lock().await))
71+ .header("atproto-accept-labelers", self.self_did.as_str())
72+ .send()
73+ .await.expect("Expected to be able to send request, but failed.");
74+ let json = response.json::<serde_json::Value>().await.expect("Expected to be able to read response as JSON, but failed.");
75+ if let Some(error) = json["error"].as_str() {
76+ match error {
77+ "InvalidRequest" => {
78+ tracing::warn!("Invalid request");
79+ return;
80+ },
81+ "ExpiredToken" => {
82+ tracing::warn!("Token expired");
83+ return;
84+ },
85+ "AccountDeactivated" => {
86+ tracing::warn!("Account deactivated");
87+ return;
88+ },
89+ "AccountTakedown" => {
90+ tracing::warn!("Account has been suspended (Takedown)");
91+ return;
92+ },
93+ _ => {
94+ tracing::warn!("Unknown error from HTTP response: {:?}", json);
95+ return;
96+ }
97+ }
98+ }
99+ *self.refresh_jwt.lock().await = json["refreshJwt"].as_str().expect("Expected to be able to read refreshJwt as str, but failed.").to_owned();
100+ *self.access_jwt.lock().await = json["accessJwt"].as_str().expect("Expected to be able to read accessJwt as str, but failed.").to_owned();
101+ let new_env = format!(
102+ "ACCESS_JWT={}\nREFRESH_JWT={}\n",
103+ self.access_jwt.lock().await, self.refresh_jwt.lock().await
104+ );
105+ fs::write(".env", new_env).expect("Failed to write to .env");
106+ tracing::info!("Token refreshed");
107+ }
108+ /// Get a JSON response from the atproto API. Used internal to this struct.
109+ async fn get(
110+ &self,
111+ path: &str,
112+ parameters: &[(&str, &str)],
113+ api_endpoint: ApiEndpoint,
114+ ) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
115+ let response = self.client_get(path, parameters, &api_endpoint).await;
116+ if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
117+ tracing::warn!("Rate limited, sleeping for 5 minutes");
118+ tracing::warn!("We were working on {} with parameters {:?}", path, parameters);
119+ tokio::time::sleep(std::time::Duration::from_secs(305)).await; // 5 minutes and 5 seconds
120+ let response = self.client_get(path, parameters, &api_endpoint).await;
121+ return Ok(response.json::<serde_json::Value>().await.expect("Expected to be able to read response as JSON, but failed."));
122+ }
123+ if response.status() == reqwest::StatusCode::BAD_REQUEST {
124+ let json = &response.json::<serde_json::Value>().await.expect("Expected to be able to read response as JSON, but failed.");
125+ match json["error"].as_str().expect("Expected to be able to read error as str, but failed.") {
126+ "ExpiredToken" => {
127+ self.client_refresh().await;
128+ let response = self.client_get(path, parameters, &api_endpoint).await;
129+ return Ok(response.json::<serde_json::Value>().await.expect("Expected to be able to read response as JSON, but failed."));
130+ },
131+ "AccountDeactivated" => {
132+ tracing::warn!("Account deactivated");
133+ return Err(Box::new(std::io::Error::new(
134+ std::io::ErrorKind::Other,
135+ "Account deactivated",
136+ )));
137+ },
138+ "AccountTakedown" => {
139+ tracing::warn!("Account has been suspended (Takedown)");
140+ return Err(Box::new(std::io::Error::new(
141+ std::io::ErrorKind::Other,
142+ "Account deactivated",
143+ )));
144+ },
145+ "InvalidRequest" => {
146+ // Check if the message is "Profile not found"
147+ if json["message"].as_str().expect("Expected to be able to read message as str, but failed.") == "Profile not found" {
148+ tracing::warn!("Profile not found");
149+ return Err(Box::new(std::io::Error::new(
150+ std::io::ErrorKind::NotFound,
151+ "Profile not found",
152+ )));
153+ }
154+ tracing::warn!("Unknown invalid request: {:?}", json);
155+ return Err(Box::new(std::io::Error::new(
156+ std::io::ErrorKind::Other,
157+ "Unknown invalid request",
158+ )));
159+ },
160+ _ => {
161+ tracing::warn!("Unknown error from HTTP response: {:?}", json);
162+ return Err(Box::new(std::io::Error::new(
163+ std::io::ErrorKind::Other,
164+ "Unknown bad request",
165+ )));
166+ }
167+ };
168+ }
169+ if response.status() != reqwest::StatusCode::OK {
170+ return Err(Box::new(std::io::Error::new(
171+ std::io::ErrorKind::Other,
172+ "Unknown HTTP error",
173+ )));
174+ }
175+ let json = response.json::<serde_json::Value>().await.expect("Expected to be able to read response as JSON, but failed.");
176+ Ok(json)
177+ }
178+ /// Get a profile from the atproto API.
179+ pub async fn get_profile(
180+ &mut self,
181+ profile_id: &str,
182+ ) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
183+ let path = "app.bsky.actor.getProfile";
184+ let parameters = [("actor", profile_id)];
185+ self.get(path, ¶meters, ApiEndpoint::Public).await
186+ }
187+ /// Get multiple profiles.
188+ pub async fn get_profiles(
189+ &mut self,
190+ profile_ids: &[String],
191+ ) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
192+ let path = "app.bsky.actor.getProfiles";
193+ let mut parameters = Vec::new();
194+ for profile_id in profile_ids {
195+ parameters.push(("actors", profile_id.as_str()));
196+ }
197+ self.get(path, parameters.as_slice(), ApiEndpoint::Authorized).await
198+ }
199+ /// Check if a list of profiles has a label from us.
200+ pub async fn check_profiles(
201+ &mut self,
202+ profile_ids: &[(String, i64)],
203+ ) -> Result<Vec<(bool, (String, i64))>, Box<dyn std::error::Error + Send + Sync>> {
204+ let mut found_labels: Vec<(bool, (String, i64))> = Vec::new();
205+ let profile_ids_uris = profile_ids.iter().map(|(profile_id, _)| profile_id.clone()).collect::<Vec<String>>();
206+ let profile_ids_seqs = profile_ids.iter().map(|(_, seq)| seq).collect::<Vec<&i64>>();
207+ let profiles = self.get_profiles(profile_ids_uris.as_slice()).await?;
208+ let profiles_array = profiles["profiles"].as_array();
209+ if profiles_array.is_none() {
210+ tracing::warn!("No profiles json found for profiles: {:?}", profiles);
211+ return Ok(vec![]);
212+ }
213+ for profile in profiles_array.unwrap_or_else(|| panic!("Expected to be able to read profiles as array, but failed. Profiles: {:?}", profiles)) {
214+ let labels = &profile["labels"];
215+ let mut found = false;
216+ let label_array = labels.as_array();
217+ if label_array.is_none() {
218+ tracing::warn!("No labels json found for profile: {:?}", profile);
219+ continue;
220+ }
221+ let did = profile["did"].as_str().expect("Expected to be able to read did as str, but failed.");
222+ let seq = profile_ids_seqs[profile_ids_uris.iter().position(|x| x == did).expect("Expected to be able to find the index of the uri.")];
223+ for label in label_array.unwrap_or_else(|| panic!("Expected to be able to read labels as array, but failed. Profile: {:?}", profile)) {
224+ if label["src"].as_str().expect("Expected to be able to read src as str, but failed.") == self.self_did.as_str() {
225+ found = true;
226+ break;
227+ }
228+ }
229+ found_labels.push((found, (did.to_owned(), *seq)));
230+ }
231+ Ok(found_labels)
232+ }
233+ /// After getting a profile, check the labels on it, and see if one from us ("src:") is there.
234+ pub async fn check_profile(
235+ &mut self,
236+ profile_did: &str,
237+ ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
238+ let profile = self.get_profile(profile_did).await?;
239+ let labels = &profile["labels"];
240+ let label_array = labels.as_array();
241+ if label_array.is_none() {
242+ tracing::warn!("No labels json found for profile: {:?}", profile);
243+ return Ok(false);
244+ }
245+ for label in label_array.unwrap_or_else(|| panic!("Expected to be able to read labels as array, but failed. Profile: {:?}", profile)) {
246+ if label["src"].as_str().expect("Expected to be able to read src as str, but failed.") == self.self_did.as_str() {
247+ return Ok(true);
248+ }
249+ }
250+ Ok(false)
251+ }
252+ /// Get a label from the provided URL, then validate the signature.
253+ pub async fn get_label_and_validate(
254+ &self,
255+ url: &str,
256+ ) -> Result<(), Box<dyn std::error::Error>> {
257+ tracing::debug!("Getting label from {}", url);
258+ let response = reqwest::get(url).await.expect("Expected to be able to get response, but failed.");
259+ tracing::debug!("Response: {:?}", response);
260+ let response_json = response.json::<serde_json::Value>().await.expect("Expected to be able to read response as JSON, but failed.");
261+ tracing::debug!("Response JSON: {:?}", response_json);
262+ let sig = &response_json["labels"][0]["sig"];
263+ tracing::debug!("Signature: {:?}", sig);
264+ let retrieved_label = RetrievedLabelResponse {
265+ // id: response_json["labels"][0]["id"].as_u64().unwrap(),
266+ cts: response_json["labels"][0]["cts"].as_str().expect("Expected to be able to read cts as str, but failed.").to_owned(),
267+ neg: response_json["labels"][0]["neg"].as_str() == Some("true"),
268+ src: response_json["labels"][0]["src"].as_str().expect("Expected to be able to read src as str, but failed.").to_owned().parse().expect("Failed to parse DID"),
269+ uri: response_json["labels"][0]["uri"].as_str().expect("Expected to be able to read uri as str, but failed.").to_owned().parse().expect("Failed to parse URI"),
270+ val: response_json["labels"][0]["val"].as_str().expect("Expected to be able to read val as str, but failed.").to_owned(),
271+ ver: response_json["labels"][0]["ver"].as_u64().expect("Expected to be able to read ver as u64, but failed."),
272+ };
273+ let crypto = crate::crypto::Crypto::new();
274+ let pub_key = "zQ3shreqyXEdouQeEQSFKfoSEN5eig74BXuqQyTaiE9uzADqZ";
275+ let sig_string = sig["$bytes"].as_str().expect("Expected to be able to read sig as str, but failed.");
276+ if crypto.validate(retrieved_label, sig_string, pub_key) {
277+ tracing::info!("Valid signature");
278+ Ok(())
279+ } else {
280+ tracing::info!("Invalid signature");
281+ Err(Box::new(std::io::Error::new(
282+ std::io::ErrorKind::Other,
283+ "Invalid signature",
284+ )))
285+ }
286+ }
287+ /// Get a label from a websocket URL, then validate the signature.
288+ /// Similar to what's done in webserve.rs, but in reverse, we'll need to decode the message.
289+ pub async fn get_label_and_validate_ws(
290+ &self,
291+ // url: &str,
292+ ) -> Result<(), Box<dyn std::error::Error>> {
293+ // For now, use this mock response, represented in base64:
294+ let response = "omF0ZyNsYWJlbHNib3ABomNzZXEYG2ZsYWJlbHOBp2NjdHN4GzIwMjUtMDItMDlUMDM6MjU6MjcuOTI4MDIzWmNuZWf0Y3NpZ1hAXLIRXAG5mF5bCWWCwEhbYvC8YYVP9fWwbVVL6IBXXlIrZ6sr6MQ4DfNdpGhwRWawA4Mq44HlEDsJ7OvcGsDCDWNzcmN4IGRpZDpwbGM6bTZhZHB0bjYyZGNhaGZhcTM0dGNlM2o1Y3VyaXggZGlkOnBsYzptNmFkcHRuNjJkY2FoZmFxMzR0Y2UzajVjdmFsbmpvaW5lZC0yMDI1LTAyY3ZlcgE=";
295+ tracing::debug!("Response: {:?}", response);
296+ let response_bytes = base64::engine::GeneralPurpose::new(
297+ &base64::alphabet::STANDARD,
298+ base64::engine::general_purpose::PAD).decode(response).expect("Expected to be able to decode base64 response.");
299+ tracing::debug!("Response bytes: {:?}", response_bytes);
300+ let reponse_bytes_in_hex = hex::encode(&response_bytes);
301+ tracing::debug!("Response bytes in hex: {:?}", reponse_bytes_in_hex);
302+ let response_0 = &response_bytes[0..response_bytes.iter().position(|&r| r == 0x01).expect("Expected to find 0x01 in response bytes.")];
303+ let response_1 = &response_bytes[response_bytes.iter().position(|&r| r == 0x01).expect("Expected to find 0x01 in response bytes.") + 1..];
304+ tracing::debug!("Response 0: {:?}", hex::encode(response_0));
305+ tracing::debug!("Response 1: {:?}", hex::encode(response_1));
306+ let response_cbor: LabelsVecWithSeq = serde_cbor::from_slice(response_1).expect("Expected to be able to deserialize response 1 as LabelsVecWithSeq, but failed.");
307+ tracing::debug!("Response CBOR: {:?}", response_cbor);
308+ let unsigned_response = RetrievedLabelResponse {
309+ cts: response_cbor.labels[0].cts.clone(),
310+ neg: response_cbor.labels[0].neg,
311+ src: response_cbor.labels[0].src.clone(),
312+ uri: response_cbor.labels[0].uri.clone(),
313+ val: response_cbor.labels[0].val.clone(),
314+ ver: response_cbor.labels[0].ver,
315+ };
316+ let sig_base64 = SignatureBytes::from_bytes(response_cbor.labels[0].sig).as_base64();
317+ tracing::debug!("Retrieved label: {:?}", response_cbor);
318+ let crypto = crate::crypto::Crypto::new();
319+ let public_key = "zQ3shreqyXEdouQeEQSFKfoSEN5eig74BXuqQyTaiE9uzADqZ";
320+ if crypto.validate(unsigned_response, &sig_base64, public_key) {
321+ tracing::info!("Valid signature");
322+ Ok(())
323+ } else {
324+ tracing::info!("Invalid signature");
325+ Err(Box::new(std::io::Error::new(
326+ std::io::ErrorKind::Other,
327+ "Invalid signature",
328+ )))
329+ }
330+ }
331+ /// getLikes
332+ pub async fn get_likes(
333+ &mut self,
334+ uri: &str,
335+ ) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error + Send + Sync>> {
336+ let path = "app.bsky.feed.getLikes";
337+ let parameters = [("uri", uri)];
338+ self.get(path, ¶meters, ApiEndpoint::Public).await.map(|response| response["likes"].as_array().expect("Expected to be able to read likes as array, but failed.").to_owned())
339+ }
340+}
···1+//! Serving requests as a labeler.
2+3+use axum::{
4+ body::Bytes,
5+ extract::ws::{Message, Utf8Bytes, WebSocket, WebSocketUpgrade},
6+ response::IntoResponse,
7+ routing::any,
8+ Router,
9+};
10+use axum_extra::TypedHeader;
11+use headers::{Header, HeaderName, HeaderValue};
12+use sqlx::{sqlite::{SqliteConnectOptions, SqliteJournalMode}, SqlitePool};
13+use std::str::FromStr;
14+use std::ops::ControlFlow;
15+use std::net::SocketAddr;
16+use axum::extract::connect_info::ConnectInfo;
17+use axum::extract::ws::CloseFrame;
18+use futures::{sink::SinkExt, stream::StreamExt};
19+use axum::extract::Query;
20+use axum::{Json, http::StatusCode, routing::get};
21+use serde::Deserialize;
22+use tower_http::decompression::RequestDecompressionLayer;
23+use tower_http::compression::CompressionLayer;
24+25+use crate::{types::{AssignedLabelResponse, AssignedLabelResponseWrapper, SignatureBytes, SignatureEnum, SubscribeLabelsLabels, UriParams}, webrequest::Agent};
26+27+28+/// Launch the web server to respond to label inquiries.
29+#[tracing::instrument]
30+pub async fn main_webserve() {
31+ let app = Router::new()
32+ .route("/xrpc/com.atproto.label.subscribeLabels", any(subscribe_labels))
33+ .route("/xrpc/com.atproto.label.queryLabels", get(query_labels))
34+ .route("/xrpc/app.bsky.actor.getProfile", get(get_profile))
35+ .layer(RequestDecompressionLayer::new())
36+ .layer(CompressionLayer::new().deflate(true));
37+ let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
38+ .await
39+ .expect("Expected to bind to 0.0.0.0:3000 but failed.");
40+ tracing::debug!("listening on {}", listener.local_addr().expect("Expected to get local address but failed."));
41+ axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.expect("Expected to be able to use axum::serve but failed.");
42+}
43+44+/// Querys by DID. \
45+async fn query_labels(Query(params): Query<UriParams>) -> impl IntoResponse {
46+ tracing::debug!("Querying labels: {:?}", params.uriPatterns);
47+ drop(dotenvy::dotenv().expect("Failed to load .env file"));
48+ let self_did = dotenvy::var("SELF_DID").expect("Expected to be able to get the SELF_DID from the environment, but failed");
49+ let src = jetstream_oxide::exports::Did::new(self_did).expect("Expected to be able to create a valid DID but failed");
50+ if let Some(uri_patterns) = params.uriPatterns {
51+ let pool_opts = SqliteConnectOptions::from_str("sqlite://prod.db?mode=ro").expect("Expected to be able to configure the database, but failed.")
52+ .journal_mode(SqliteJournalMode::Wal)
53+ .read_only(true);
54+ let pool = SqlitePool::connect_with(pool_opts).await.expect("Expected to be able to connect to the database at sqlite://prod.db but failed.");
55+ let pattern = uri_patterns.replace("%", "").replace("_", "\\_"); // .replaceAll(/%/g, "").replaceAll(/_/g, "\\_");
56+ let star_index = pattern.find('*');
57+ let limit = params.limit.unwrap_or(50);
58+ let cursor = params.cursor.unwrap_or_else(|| "0".to_owned());
59+ if let Some(star_index) = star_index {
60+ if star_index != pattern.len() - 1 {
61+ return (StatusCode::BAD_REQUEST, Json(AssignedLabelResponseWrapper {
62+ cursor: "0".to_owned(), // TODO: Other servers don't respond with a cursor in this scenario.
63+ labels: Vec::<AssignedLabelResponse>::new()
64+ }));
65+ }
66+ let labels = sqlx::query!(
67+ r#"
68+ SELECT seq, uri "uri: String", val "val: String", neg, cts "cts: String", sig
69+ FROM profile_labels
70+ WHERE seq > ?
71+ LIMIT ?
72+ "#,
73+ cursor,
74+ limit
75+ )
76+ .fetch_all(&pool)
77+ .await
78+ .expect("Expected to be able to fetch all labels from the database but failed.");
79+ let smallest_cursor = labels.iter().map(|label| label.seq).min().unwrap_or(0);
80+ return (StatusCode::OK, Json(AssignedLabelResponseWrapper {
81+ cursor: smallest_cursor.to_string(),
82+ labels: labels
83+ .iter()
84+ .map(|label| {
85+ AssignedLabelResponse::reconstruct(
86+ src.to_owned(),
87+ label.uri.clone(),
88+ label.val.clone(),
89+ label.neg.unwrap_or(false),
90+ label.cts.clone(),
91+ SignatureEnum::Json(SignatureBytes::from_vec(label.sig.clone()).as_json_object()),
92+ )
93+ })
94+ .collect(),
95+ }));
96+ }
97+ let labels = sqlx::query!(
98+ r#"
99+ SELECT seq "seq: i64", uri "uri: String", val "val: String", neg, cts "cts: String", sig
100+ FROM profile_labels WHERE uri = ? AND seq > ? LIMIT ?
101+ "#,
102+ uri_patterns,
103+ cursor,
104+ limit
105+ )
106+ .fetch_all(&pool)
107+ .await
108+ .expect("Expected to be able to fetch all missing labels from the database but failed.");
109+ let largest_cursor = labels.iter().map(|label| label.seq).max().unwrap_or(0);
110+ return (StatusCode::OK, Json(AssignedLabelResponseWrapper {
111+ cursor: largest_cursor.to_string(),
112+ labels: labels
113+ .iter()
114+ .map(|label| {
115+ AssignedLabelResponse::reconstruct(
116+ src.to_owned(),
117+ label.uri.clone(),
118+ label.val.clone(),
119+ label.neg.unwrap_or(false),
120+ label.cts.clone(),
121+ SignatureEnum::Json(SignatureBytes::from_vec(label.sig.clone()).as_json_object()),
122+ )
123+ })
124+ .collect(),
125+ }));
126+ }
127+ (
128+ StatusCode::OK,
129+ Json(AssignedLabelResponseWrapper {
130+ cursor: "0".to_owned(),
131+ labels: Vec::<AssignedLabelResponse>::new()
132+ }),
133+ )
134+}
135+/// Querys by profile name.
136+async fn get_profile(Query(params): Query<UriParams>) -> impl IntoResponse {
137+ if let Some(actor) = ¶ms.actor {
138+ let mut agent = Agent::default();
139+ if let Ok(profile) = agent.get_profile(actor).await {
140+ return (StatusCode::OK, Json(profile));
141+ }
142+ }
143+ (StatusCode::OK, Json(serde_json::json!({})))
144+}
145+146+/// Query parameters for subscribing to labels.
147+#[derive(Deserialize)]
148+struct SubscribeLabelsQueryParams {
149+ /// The last known event seq number to backfill from.
150+ cursor: Option<u64>,
151+}
152+153+154+#[derive(Debug)]
155+struct XForwardedFor(String);
156+157+impl Header for XForwardedFor {
158+ fn name() -> &'static HeaderName {
159+ &http::header::FORWARDED
160+ }
161+162+ fn decode<'i, I>(values: &mut I) -> Result<Self, headers::Error>
163+ where
164+ I: Iterator<Item = &'i HeaderValue>,
165+ {
166+ let value = values
167+ .next()
168+ .ok_or_else(headers::Error::invalid)?;
169+170+ // We are only interested in the first IP address in the list.
171+ let ip = value
172+ .to_str()
173+ .map_err(|_| headers::Error::invalid())?
174+ .split(',')
175+ .next()
176+ .ok_or_else(headers::Error::invalid)?;
177+178+ Ok(Self(ip.to_owned()))
179+ }
180+181+ fn encode<E>(&self, values: &mut E)
182+ where
183+ E: Extend<HeaderValue>,
184+ {
185+ let value = HeaderValue::from_str(&self.0).expect("Expected to be able to convert the X-Forwarded-For header to a string but failed.");
186+ values.extend(std::iter::once(value));
187+ }
188+}
189+190+/// The handler for the HTTP request.
191+///
192+/// This gets called when the HTTP request lands at the start
193+/// of websocket negotiation. After this completes, the actual switching from HTTP to
194+/// websocket protocol will occur.
195+/// This is the last point where we can extract TCP/IP metadata such as IP address of the client
196+/// as well as things from HTTP headers such as user-agent of the browser etc.
197+async fn subscribe_labels(
198+ ws: WebSocketUpgrade,
199+ user_agent: Option<TypedHeader<headers::UserAgent>>,
200+ x_forwarded_for: Option<TypedHeader<XForwardedFor>>,
201+ ConnectInfo(connection_address): ConnectInfo<SocketAddr>,
202+ Query(params): Query<SubscribeLabelsQueryParams>,
203+) -> impl IntoResponse {
204+ let user_agent = if let Some(TypedHeader(user_agent)) = user_agent {
205+ user_agent.to_string()
206+ } else {
207+ String::from("Unknown browser")
208+ };
209+ // Check X-Forwarded-For header to get the apparent IP address of the client
210+ // TODO: This header can be spoofed, and should only be trusted from a trusted proxy.
211+ let apparent_ip = if let Some(TypedHeader(x_forwarded_for)) = x_forwarded_for {
212+ SocketAddr::new(x_forwarded_for.0.parse().expect("Expected to be able to parse the X-Forwarded-For header as a socket address but failed."),
213+ connection_address.port())
214+ } else {
215+ connection_address
216+ };
217+ tracing::debug!("`{user_agent}` at {apparent_ip} connected.");
218+ let pool_opts = SqliteConnectOptions::from_str("sqlite://prod.db?mode=ro").expect("Expected to be able to configure the database, but failed.")
219+ .journal_mode(SqliteJournalMode::Wal)
220+ .read_only(true);
221+ let pool = SqlitePool::connect_with(pool_opts).await.expect("Expected to be able to connect to the database at sqlite://prod.db but failed.");
222+ let cursor = params.cursor.unwrap_or(
223+ get_current_cursor_count(&pool)
224+ .await.expect("Expected to be able to get the current cursor count but failed.")
225+ .try_into().expect("Expected to be able to convert the current cursor count to a u64 but failed.")
226+ ) as i64;
227+ // finalize the upgrade process by returning upgrade callback.
228+ // we can customize the callback by sending additional info such as address.
229+ ws.on_upgrade(move |socket| handle_socket(socket, apparent_ip, cursor, pool))
230+}
231+232+async fn get_current_cursor_count(
233+ pool: &SqlitePool,
234+) -> Result<i64, sqlx::Error> {
235+ let current_cursor_count = sqlx::query!(
236+ r#"
237+ SELECT seq FROM profile_labels ORDER BY seq DESC LIMIT 1
238+ "#
239+ )
240+ .fetch_one(pool)
241+ .await?;
242+ Ok(current_cursor_count.seq)
243+}
244+245+/// Actual websocket statemachine (one will be spawned per connection)
246+async fn handle_socket(socket: WebSocket, who: SocketAddr, cursor: i64, pool: SqlitePool) {
247+ let _ = websocket_context(socket, who, cursor, pool).await;
248+ // returning from the handler closes the websocket connection
249+ tracing::debug!("Websocket context {who} destroyed");
250+}
251+252+/// Get all missed messages, based on cursor.
253+async fn get_missed_messages(
254+ pool: &SqlitePool,
255+ cursor: i64,
256+) -> Result<Vec<SubscribeLabelsLabels>, sqlx::Error> {
257+ let missed_messages = sqlx::query!(
258+ r#"
259+ SELECT seq, uri "uri: String", val "val: String", neg "neg: bool", cts "cts: String", sig
260+ FROM profile_labels WHERE seq > ?
261+ "#,
262+ cursor
263+ )
264+ .fetch_all(pool)
265+ .await?;
266+ drop(dotenvy::dotenv().expect("Failed to load .env file"));
267+ let self_did = dotenvy::var("SELF_DID").expect("Expected to be able to get the SELF_DID from the environment, but failed");
268+ let src = jetstream_oxide::exports::Did::new(self_did).expect("Expected to be able to create a valid DID but failed");
269+ Ok(missed_messages
270+ .iter()
271+ .map(|label| {
272+ SubscribeLabelsLabels {
273+ seq: label.seq,
274+ labels: vec![AssignedLabelResponse::reconstruct(
275+ src.to_owned(),
276+ label.uri.clone(),
277+ label.val.clone(),
278+ label.neg.unwrap_or(false),
279+ label.cts.clone(),
280+ SignatureEnum::Bytes(SignatureBytes::from_vec(label.sig.clone())),
281+ )],
282+ }
283+ })
284+ .collect())
285+}
286+287+async fn websocket_context(mut socket: WebSocket, who: SocketAddr, mut cursor: i64, pool: SqlitePool) -> ControlFlow<()> {
288+ ws_send(
289+ &mut socket,
290+ who,
291+ Message::Ping(Bytes::from_static(&[1, 2, 3]))
292+ ).await?;
293+ tracing::info!("{who} connected with cursor {cursor}");
294+ let current_cursor_count = get_current_cursor_count(&pool).await.unwrap_or_default();
295+ tracing::debug!("Current cursor count: {current_cursor_count}");
296+ if cursor < current_cursor_count {
297+ let missed_messages = get_missed_messages(&pool, cursor).await.expect("Expected to be able to get missed messages but failed.");
298+ for message in missed_messages {
299+ tracing::info!("Sending missed message to {who}: {:?}", message);
300+ let message_header: Vec<u8> = Bytes::from_static(b"\xa2atg#labelsbop\x01").into();
301+ let message_body = serde_cbor::to_vec(&message).expect("Expected to be able to serialize message to CBOR but failed.");
302+ let message_combined = [message_header, message_body].concat();
303+ let message_finished = Message::Binary(message_combined.into());
304+ ws_send(
305+ &mut socket,
306+ who,
307+ message_finished
308+ ).await?;
309+ }
310+ cursor = current_cursor_count;
311+ }
312+ // By splitting socket we can send and receive at the same time. In this example we will send
313+ // unsolicited messages to client based on some sort of server's internal event (i.e .timer).
314+ let (mut sender, mut receiver) = socket.split();
315+ // Spawn a task that will push several messages to the client (does not matter what client does)
316+ let mut send_task = tokio::spawn(async move {
317+ const PING_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
318+ const BROADCAST_INTERVAL: std::time::Duration = std::time::Duration::from_secs(20);
319+ let mut last_ping = tokio::time::Instant::now();
320+ let mut last_broadcast = tokio::time::Instant::now();
321+ let mut n_msg = 0;
322+ loop {
323+ tokio::select! {
324+ _ = tokio::time::sleep_until(last_ping + PING_INTERVAL) => {
325+ tracing::debug!("Sending ping to {who}...");
326+ if ws_send(
327+ &mut sender,
328+ who,
329+ Message::Ping(Bytes::from_static(&[1, 2, 3]))
330+ ).await.is_break() {
331+ tracing::warn!("Client {who} failed to respond to ping");
332+ break;
333+ }
334+ tracing::debug!("Sent ping to {who}");
335+ last_ping = tokio::time::Instant::now();
336+ },
337+ _ = tokio::time::sleep_until(last_broadcast + BROADCAST_INTERVAL) => {
338+ tracing::debug!("Polling for new messages to send to {who}...");
339+ let current_cursor_count = get_current_cursor_count(&pool).await.unwrap_or_default();
340+ if cursor < current_cursor_count {
341+ let missed_messages = get_missed_messages(&pool, cursor).await;
342+ if missed_messages.is_err() {
343+ tracing::warn!("Error getting missed messages: {missed_messages:?}");
344+ last_broadcast = tokio::time::Instant::now();
345+ continue;
346+ }
347+ for message in missed_messages.expect("Expected to be able to get missed messages but failed.") {
348+ let seq = message.seq;
349+ let neg = message.labels[0].neg;
350+ let uri = message.labels[0].uri.clone();
351+ let val = message.labels[0].val.clone();
352+ let prefix = if neg { "Negation" } else { "Emitting" };
353+ tracing::info!("{prefix} label {seq} to {who}: {uri} {val}");
354+ let message_header: Vec<u8> = Bytes::from_static(b"\xa2atg#labelsbop\x01").into();
355+ let message_body = serde_cbor::to_vec(&message).expect("Expected to be able to serialize message to CBOR but failed.");
356+ let message_combined = [message_header, message_body].concat();
357+ let message_finished = Message::Binary(message_combined.into());
358+ if ws_send(
359+ &mut sender,
360+ who,
361+ message_finished
362+ ).await.is_break() {
363+ tracing::warn!("Client {who} failed to receive missed message");
364+ break;
365+ }
366+ n_msg += 1;
367+ }
368+ cursor = current_cursor_count;
369+ }
370+ tracing::debug!("Finished poll for {who}");
371+ last_broadcast = tokio::time::Instant::now();
372+ }
373+ }
374+ }
375+ tracing::info!("Sending close to {who}...");
376+ ws_close(sender).await;
377+ n_msg
378+ });
379+380+ // This second task will receive messages from client and print them on server console
381+ let mut recv_task = tokio::spawn(async move {
382+ let mut cnt = 0;
383+ while let Some(Ok(msg)) = receiver.next().await {
384+ cnt += 1;
385+ // print message and break if instructed to do so
386+ if process_message(msg, who).is_break() {
387+ break;
388+ }
389+ }
390+ cnt
391+ });
392+393+ // If any one of the tasks exit, abort the other.
394+ tokio::select! {
395+ rv_a = (&mut send_task) => {
396+ match rv_a {
397+ Ok(a) => tracing::info!("{a} messages sent to {who}"),
398+ Err(a) => tracing::warn!("Error sending messages {a:?}")
399+ }
400+ recv_task.abort();
401+ },
402+ rv_b = (&mut recv_task) => {
403+ match rv_b {
404+ Ok(b) => tracing::info!("Received {b} messages"),
405+ Err(b) => tracing::warn!("Error receiving messages {b:?}")
406+ }
407+ send_task.abort();
408+ }
409+ }
410+ ControlFlow::Continue(())
411+}
412+413+async fn ws_send(
414+ socket: &mut (impl SinkExt<Message> + Unpin),
415+ who: SocketAddr,
416+ msg: Message,
417+) -> ControlFlow<(), ()> {
418+ if socket
419+ .send(msg)
420+ .await
421+ .is_err()
422+ {
423+ tracing::warn!("client {who} abruptly disconnected");
424+ return ControlFlow::Break(());
425+ }
426+ ControlFlow::Continue(())
427+}
428+429+async fn ws_close(mut sender: futures::stream::SplitSink<WebSocket, Message>) {
430+ if let Err(e) = sender
431+ .send(Message::Close(Some(CloseFrame {
432+ code: axum::extract::ws::close_code::NORMAL,
433+ reason: Utf8Bytes::from_static("Goodbye"),
434+ })))
435+ .await
436+ {
437+ tracing::warn!("Could not send Close due to {e}, probably it is ok?");
438+ }
439+}
440+441+/// helper to print contents of messages to stdout. Has special treatment for Close.
442+#[allow(clippy::cognitive_complexity)]
443+fn process_message(msg: Message, who: SocketAddr) -> ControlFlow<(), ()> {
444+ match msg {
445+ Message::Text(t) => {
446+ tracing::debug!(">>> {who} sent str: {t:?}");
447+ }
448+ Message::Binary(d) => {
449+ tracing::debug!(">>> {} sent {} bytes: {:?}", who, d.len(), d);
450+ }
451+ Message::Close(c) => {
452+ if let Some(cf) = c {
453+ tracing::debug!(
454+ ">>> {} sent close with code {} and reason `{}`",
455+ who, cf.code, cf.reason
456+ );
457+ } else {
458+ tracing::debug!(">>> {who} somehow sent close message without CloseFrame");
459+ }
460+ return ControlFlow::Break(());
461+ }
462+463+ Message::Pong(v) => {
464+ tracing::debug!(">>> {who} sent pong with {v:?}");
465+ }
466+ // You should never need to manually handle Message::Ping, as axum's websocket library
467+ // will do so for you automagically by replying with Pong and copying the v according to
468+ // spec. But if you need the contents of the pings you can see them here.
469+ Message::Ping(v) => {
470+ tracing::debug!(">>> {who} sent ping with {v:?}");
471+ }
472+ }
473+ ControlFlow::Continue(())
474+}
475+476+/// WIP: fetch likes from app.bsky.feed.like
477+/// https://shimeji.us-east.host.bsky.network/xrpc/com.atproto.repo.listRecords?repo=did:plc:jrtgsidnmxaen4offglr5lsh&collection=app.bsky.feed.like&limit=100
478+///
479+/// then return them as a custom feed
480+/// request path is at "/xrpc/app.bsky.feed.getFeedSkeleton?<feed>&<limit>&<cursor>"
481+#[allow(dead_code)]
482+async fn get_feed_skeleton(Query(params): Query<UriParams>) -> impl IntoResponse {
483+ if let Some(_uri_patterns) = ¶ms.uriPatterns {
484+ let mut _agent = Agent::default();
485+486+ }
487+ (StatusCode::OK, Json(serde_json::json!({})))
488+}