···9use tokio::sync::Mutex;
10use tokio::sync::mpsc;
1112+/// Receiver for `BlahgEvent` instances from the jetstream consumer.
13pub type BlahgEventReceiver = mpsc::UnboundedReceiver<BlahgEvent>;
1415+/// ATProtocol events relevant to the blog application.
16#[derive(Debug, Clone)]
17pub enum BlahgEvent {
18+ /// A record was committed to the ATProtocol network.
19 Commit {
20+ /// The DID of the record author
21 did: String,
22+ /// The collection name
23 collection: String,
24+ /// The record key
25 rkey: String,
26+ /// The content identifier
27 cid: String,
28+ /// The record data
29 record: serde_json::Value,
30 },
31+ /// A record was deleted from the ATProtocol network.
32 Delete {
33+ /// The DID of the record author
34 did: String,
35+ /// The collection name
36 collection: String,
37+ /// The record key
38 rkey: String,
39 },
40}
4142+/// Handler for processing ATProtocol events and converting them to `BlahgEvent` instances.
43pub struct BlahgEventHandler {
44 id: String,
45 event_sender: mpsc::UnboundedSender<BlahgEvent>,
···168 }
169}
170171+/// Consumer for creating ATProtocol event handlers.
172pub struct Consumer {}
173174impl Consumer {
175+ /// Create a new Blahg event handler that processes ATProtocol events.
176 pub fn create_blahg_handler(&self) -> (Arc<BlahgEventHandler>, BlahgEventReceiver) {
177 let (sender, receiver) = mpsc::unbounded_channel();
178 let handler = Arc::new(BlahgEventHandler::new(
···182 (handler, receiver)
183 }
184185+ /// Create a new cursor writer handler that persists jetstream cursor position.
186 pub fn create_cursor_writer_handler(&self, cursor_path: String) -> Arc<CursorWriterHandler> {
187 Arc::new(CursorWriterHandler::new(
188 "cursor-writer".to_string(),
-82
src/identity.rs
···1use atproto_identity::model::Document;
2use atproto_identity::resolve::IdentityResolver;
3-use atproto_identity::storage::DidDocumentStorage;
4use chrono::Utc;
5use std::sync::Arc;
6···64 Ok(())
65 }
66}
67-68-/// A variant that works with DidDocumentStorage instead of IdentityStorage
69-pub(crate) struct CachingDidDocumentResolver<T: DidDocumentStorage + ?Sized> {
70- /// The underlying identity resolver to use when cache misses occur
71- resolver: IdentityResolver,
72- /// The storage implementation to use for caching
73- storage: Arc<T>,
74-}
75-76-impl<T: DidDocumentStorage + ?Sized> CachingDidDocumentResolver<T> {
77- /// Create a new caching identity resolver with the given resolver and storage.
78- pub(crate) fn new(resolver: IdentityResolver, storage: Arc<T>) -> Self {
79- Self { resolver, storage }
80- }
81-82- /// Resolve a DID to a Document, using the cache when possible.
83- pub(crate) async fn resolve(&self, did: &str) -> Result<Document> {
84- // First, try to get the document from storage
85- if let Some(document) = self.storage.get_document_by_did(did).await? {
86- return Ok(document);
87- }
88-89- // If not in storage, resolve using the underlying resolver
90- let document = self.resolver.resolve(did).await?;
91-92- // Store the resolved document for future lookups
93- self.storage.store_document(document.clone()).await?;
94-95- Ok(document)
96- }
97-98- /// Resolve a handle to a Document, using the cache when possible.
99- pub(crate) async fn resolve_handle(&self, handle: &str) -> Result<Document> {
100- // For handle resolution, we need to resolve first since DidDocumentStorage
101- // doesn't have a get_by_handle method
102- let document = self.resolver.resolve(handle).await?;
103-104- // Store the resolved document for future lookups
105- self.storage.store_document(document.clone()).await?;
106-107- Ok(document)
108- }
109-}
110-111-#[cfg(test)]
112-mod tests {
113- use super::*;
114- use crate::storage::sqlite::SqliteStorage;
115- use atproto_identity::resolve::{InnerIdentityResolver, create_resolver};
116- use sqlx::SqlitePool;
117- use std::sync::Arc;
118-119- #[tokio::test]
120- async fn test_caching_identity_resolver() -> Result<()> {
121- // Create an in-memory SQLite database for testing
122- let pool = SqlitePool::connect("sqlite::memory:").await?;
123- let storage = Arc::new(SqliteStorage::new(pool));
124-125- // Run migrations using the Storage trait
126- use crate::storage::Storage;
127- storage.migrate().await?;
128-129- // Create a mock resolver (this would normally resolve from the network)
130- let dns_resolver = create_resolver(&[]);
131- let http_client = reqwest::Client::new();
132- let inner_resolver = InnerIdentityResolver {
133- dns_resolver,
134- http_client,
135- plc_hostname: "plc.directory".to_string(),
136- };
137- let resolver = IdentityResolver(Arc::new(inner_resolver));
138-139- // Create the caching resolver
140- let caching_resolver = CachingIdentityResolver::new(resolver, storage.clone());
141-142- // Test would go here - this is just a skeleton since we'd need real DIDs
143- // and network access to properly test the resolution
144-145- Ok(())
146- }
147-}
···1use chrono::{DateTime, Utc};
2use serde::Deserialize;
34+/// A blob record containing binary data metadata.
5#[derive(Debug, Clone, Deserialize)]
6pub struct BlobRecord {
7+ /// The record type
8 #[serde(rename = "$type")]
9 pub r#type: String,
1011+ /// MIME type of the blob
12 #[serde(rename = "mimeType")]
13 pub mime_type: String,
14+ /// Size of the blob in bytes
15 pub size: i64,
1617+ /// Reference to the blob content
18 #[serde(rename = "ref")]
19 pub r#ref: BlobRef,
20}
2122+/// A reference to blob content via CID link.
23#[derive(Debug, Clone, Deserialize)]
24pub struct BlobRef {
25+ /// The CID link to the blob content
26 #[serde(rename = "$link")]
27 pub link: String,
28}
2930+/// An attachment to a blog post.
31#[derive(Debug, Clone, Deserialize)]
32pub struct PostAttachment {
33+ /// The attachment type
34 #[serde(rename = "$type")]
35 pub r#type: String,
3637+ /// The blob content of the attachment
38 pub content: BlobRecord,
39}
4041+/// A blog post record from the ATProtocol lexicon.
42#[derive(Debug, Clone, Deserialize)]
43pub struct PostRecord {
44+ /// The record type
45 #[serde(rename = "$type")]
46 pub r#type: String,
4748+ /// Title of the blog post
49 pub title: String,
50+ /// Main content of the blog post
51 pub content: BlobRecord,
5253+ /// Publication timestamp
54 #[serde(rename = "publishedAt")]
55 pub published_at: DateTime<Utc>,
5657+ /// List of attachments to the post
58 #[serde(default = "empty_attachments")]
59 pub attachments: Vec<PostAttachment>,
6061+ /// Languages used in the post
62 pub langs: Vec<String>,
63}
64
+34
src/lib.rs
···0000000000000000000000001#![warn(missing_docs)]
203pub mod config;
04pub mod consumer;
05pub mod errors;
06pub mod http;
07pub mod identity;
08pub mod lexicon;
09pub mod process;
010pub mod render;
011pub mod storage;
012pub mod templates;
···1+//! # Blahg - ATProtocol Blog AppView
2+//!
3+//! Blahg is a Rust-based ATProtocol AppView that renders personal blog content.
4+//! It consumes ATProtocol records to create a blog-like experience by discovering
5+//! and displaying relevant posts.
6+//!
7+//! ## Architecture
8+//!
9+//! - **ATProtocol Integration**: Uses the atproto ecosystem crates for identity resolution and record processing
10+//! - **Storage Layer**: Flexible storage backends including SQLite, PostgreSQL, and content storage
11+//! - **Rendering**: Markdown rendering with syntax highlighting
12+//! - **Event Processing**: Real-time consumption of ATProtocol events via jetstream
13+//!
14+//! ## Key Components
15+//!
16+//! - [`config`] - Configuration management
17+//! - [`consumer`] - ATProtocol event consumption
18+//! - [`process`] - Event processing and record handling
19+//! - [`storage`] - Data persistence layer
20+//! - [`render`] - Markdown rendering
21+//! - [`http`] - Web server and API endpoints
22+//! - [`identity`] - ATProtocol identity resolution
23+//! - [`lexicon`] - ATProtocol record type definitions
24+25#![warn(missing_docs)]
2627+/// Configuration management for the Blahg application.
28pub mod config;
29+/// ATProtocol event consumption and handling.
30pub mod consumer;
31+/// Error types and handling for the Blahg application.
32pub mod errors;
33+/// HTTP server and web API endpoints.
34pub mod http;
35+/// ATProtocol identity resolution and caching.
36pub mod identity;
37+/// ATProtocol record type definitions and lexicon.
38pub mod lexicon;
39+/// Event processing and record handling logic.
40pub mod process;
41+/// Markdown rendering with syntax highlighting.
42pub mod render;
43+/// Data persistence layer with multiple backend support.
44pub mod storage;
45+/// Template management and rendering.
46pub mod templates;
+4-13
src/process.rs
···28#[derive(Debug, Deserialize, Serialize, Default)]
29struct StrongRef {
30 #[serde(rename = "$type")]
31- pub type_: Option<String>,
32- pub uri: String,
33- pub cid: String,
34}
3536/// app.bsky.feed.post record structure
37#[derive(Debug, Deserialize)]
38struct FeedPostRecord {
39 #[serde(rename = "$type")]
40- pub type_: Option<String>,
4142 #[serde(default)]
43 facets: Vec<Facet>,
···409 }
410411 Ok(())
412- }
413-414- /// Check if an AT-URI references a post from a known author
415- fn is_known_author_post(&self, uri: &str) -> bool {
416- if let Ok(aturi) = ATURI::from_str(uri) {
417- return aturi.collection == "tools.smokesignal.blahg.content.post"
418- && self.config.author == aturi.authority;
419- }
420- false
421 }
422423 /// Creates a post prefix lookup hashmap with external URL prefixes mapped to AT-URIs
···5use serde_json::Value;
6use std::collections::HashMap;
78+/// An ATProtocol identity record.
9#[derive(Clone, Serialize, Deserialize, sqlx::FromRow)]
10pub struct Identity {
11+ /// The decentralized identifier (DID) of the identity
12+ pub (crate) did: String,
13+ /// The handle associated with the identity
14+ pub (crate) handle: String,
15+ /// The identity document as JSON
16+ pub (crate) record: Value,
17+ /// When the identity was first created
18+ pub (crate) created_at: DateTime<Utc>,
19+ /// When the identity was last updated
20+ pub (crate) updated_at: DateTime<Utc>,
21}
2223+/// A blog post record from the `tools.smokesignal.blahg.content.post` collection.
24#[derive(Clone, Serialize, Deserialize, sqlx::FromRow)]
25pub struct Post {
26+ /// The AT-URI of the post record
27 pub aturi: String,
28+ /// The content identifier (CID) of the post
29 pub cid: String,
30+ /// The title of the blog post
31 pub title: String,
32+ /// The URL slug for the post
33 pub slug: String,
34+ /// The markdown content of the post
35 pub content: String,
36+ /// The record key within the collection
37 pub record_key: String,
38+ /// When the post was first created
39 pub created_at: DateTime<Utc>,
40+ /// When the post was last updated
41 pub updated_at: DateTime<Utc>,
42+ /// The original ATProtocol record as JSON
43 pub record: Value,
44}
4546+/// A reference to a blog post from another ATProtocol record.
47#[derive(Clone, Serialize, Deserialize, sqlx::FromRow)]
48pub struct PostReference {
49+ /// The AT-URI of the reference record
50 pub aturi: String,
51+ /// The content identifier (CID) of the reference
52 pub cid: String,
53+ /// The DID of the author who created the reference
54 pub did: String,
55+ /// The collection containing the reference
56 pub collection: String,
57+ /// The AT-URI of the post being referenced
58 pub post_aturi: String,
59+ /// When the reference was discovered
60 pub discovered_at: DateTime<Utc>,
61+ /// The original reference record as JSON
62 pub record: Value,
63}
6465+/// Trait for storing and retrieving blog posts and post references.
66#[async_trait]
67pub trait PostStorage: Send + Sync {
68+ /// Insert or update a blog post.
69 async fn upsert_post(&self, post: &Post) -> Result<()>;
7071+ /// Get a blog post by its AT-URI.
72 async fn get_post(&self, aturi: &str) -> Result<Option<Post>>;
7374+ /// Get all blog posts.
75 async fn get_posts(&self) -> Result<Vec<Post>>;
7677+ /// Delete a blog post by its AT-URI.
78 async fn delete_post(&self, aturi: &str) -> Result<Option<Post>>;
7980+ /// Insert or update a post reference.
81 async fn upsert_post_reference(&self, post_reference: &PostReference) -> Result<bool>;
8283+ /// Delete a post reference by its AT-URI.
84 async fn delete_post_reference(&self, aturi: &str) -> Result<()>;
8586+ /// Get the count of references to a post grouped by collection.
87 async fn get_post_reference_count(&self, post_aturi: &str) -> Result<HashMap<String, i64>>;
8889+ /// Get all references to a specific post.
90 async fn get_post_references_for_post(&self, post_aturi: &str) -> Result<Vec<PostReference>>;
9192+ /// Get references to a post from a specific collection.
93 async fn get_post_references_for_post_for_collection(
94 &self,
95 post_aturi: &str,
···97 ) -> Result<Vec<PostReference>>;
98}
99100+/// Trait for storing and retrieving ATProtocol identities.
101#[async_trait]
102pub trait IdentityStorage: Send + Sync {
103+ /// Insert or update an identity record.
104 async fn upsert_identity(&self, identity: &Identity) -> Result<()>;
105106+ /// Get an identity by its DID.
107 async fn get_identity_by_did(&self, did: &str) -> Result<Option<Identity>>;
108109+ /// Get an identity by its handle.
110 async fn get_identity_by_handle(&self, handle: &str) -> Result<Option<Identity>>;
111112+ /// Delete an identity by its AT-URI.
113 async fn delete_identity(&self, aturi: &str) -> Result<Option<Identity>>;
114}
115116+/// Trait for storing and retrieving content by CID.
117#[async_trait]
118pub trait ContentStorage: Send + Sync {
119+ /// Check if content exists for a given CID.
120 async fn content_exists(&self, cid: &str) -> Result<bool>;
121122+ /// Write content data for a given CID.
123 async fn write_content(&self, cid: &str, data: &[u8]) -> Result<()>;
124125+ /// Read content data for a given CID.
126 async fn read_content(&self, cid: &str) -> Result<Vec<u8>>;
127}
128129+/// Combined trait for all storage operations.
130#[async_trait]
131pub trait Storage: PostStorage + IdentityStorage + Send + Sync {
132+ /// Run database migrations.
133 async fn migrate(&self) -> Result<()>;
134}
135136+/// Cached storage implementations.
137pub mod cached;
138+/// Content storage implementations.
139pub mod content;
140+/// PostgreSQL storage implementation.
141pub mod postgres;
142+/// SQLite storage implementation.
143#[cfg(feature = "sqlite")]
144pub mod sqlite;
145