···66/// Application configuration loaded from environment variables.
77#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Config {
99+ /// HTTP server configuration
910 pub http: HttpConfig,
1111+ /// Base URL for external links and content
1012 pub external_base: String,
1313+ /// Certificate bundles for TLS verification
1114 pub certificate_bundles: Vec<String>,
1515+ /// User agent string for HTTP requests
1216 pub user_agent: String,
1717+ /// Hostname for PLC directory lookups
1318 pub plc_hostname: String,
1919+ /// DNS nameservers for resolution
1420 pub dns_nameservers: Vec<std::net::IpAddr>,
2121+ /// HTTP client timeout duration
1522 pub http_client_timeout: Duration,
2323+ /// Author name for the blog
1624 pub author: String,
2525+ /// Storage path for attachments
1726 pub attachment_storage: String,
2727+ /// Database connection URL
1828 pub database_url: String,
2929+ /// Path to jetstream cursor file
1930 pub jetstream_cursor_path: Option<String>,
3131+ /// Whether to enable jetstream event consumption
2032 pub enable_jetstream: bool,
3333+ /// Syntect theme for syntax highlighting
3434+ pub syntect_theme: String,
2135}
22363737+/// HTTP server configuration options.
2338#[derive(Debug, Clone, Serialize, Deserialize)]
2439pub struct HttpConfig {
4040+ /// Port to bind the HTTP server to
2541 pub port: u16,
4242+ /// Path to static assets
2643 pub static_path: String,
4444+ /// Path to template files
2745 pub templates_path: String,
2846}
2947···4563 database_url: "sqlite://blahg.db".to_string(),
4664 jetstream_cursor_path: None,
4765 enable_jetstream: true,
6666+ syntect_theme: std::env::var("SYNTECT_THEME").unwrap_or_else(|_| "base16-ocean.dark".to_string()),
4867 }
4968 }
5069}
···136155137156 if let Ok(enable_jetstream) = std::env::var("ENABLE_JETSTREAM") {
138157 config.enable_jetstream = enable_jetstream.parse().unwrap_or(true);
158158+ }
159159+160160+ if let Ok(syntect_theme) = std::env::var("SYNTECT_THEME") {
161161+ config.syntect_theme = syntect_theme;
139162 }
140163141164 Ok(config)
+16
src/consumer.rs
···99use tokio::sync::Mutex;
1010use tokio::sync::mpsc;
11111212+/// Receiver for `BlahgEvent` instances from the jetstream consumer.
1213pub type BlahgEventReceiver = mpsc::UnboundedReceiver<BlahgEvent>;
13141515+/// ATProtocol events relevant to the blog application.
1416#[derive(Debug, Clone)]
1517pub enum BlahgEvent {
1818+ /// A record was committed to the ATProtocol network.
1619 Commit {
2020+ /// The DID of the record author
1721 did: String,
2222+ /// The collection name
1823 collection: String,
2424+ /// The record key
1925 rkey: String,
2626+ /// The content identifier
2027 cid: String,
2828+ /// The record data
2129 record: serde_json::Value,
2230 },
3131+ /// A record was deleted from the ATProtocol network.
2332 Delete {
3333+ /// The DID of the record author
2434 did: String,
3535+ /// The collection name
2536 collection: String,
3737+ /// The record key
2638 rkey: String,
2739 },
2840}
29414242+/// Handler for processing ATProtocol events and converting them to `BlahgEvent` instances.
3043pub struct BlahgEventHandler {
3144 id: String,
3245 event_sender: mpsc::UnboundedSender<BlahgEvent>,
···155168 }
156169}
157170171171+/// Consumer for creating ATProtocol event handlers.
158172pub struct Consumer {}
159173160174impl Consumer {
175175+ /// Create a new Blahg event handler that processes ATProtocol events.
161176 pub fn create_blahg_handler(&self) -> (Arc<BlahgEventHandler>, BlahgEventReceiver) {
162177 let (sender, receiver) = mpsc::unbounded_channel();
163178 let handler = Arc::new(BlahgEventHandler::new(
···167182 (handler, receiver)
168183 }
169184185185+ /// Create a new cursor writer handler that persists jetstream cursor position.
170186 pub fn create_cursor_writer_handler(&self, cursor_path: String) -> Arc<CursorWriterHandler> {
171187 Arc::new(CursorWriterHandler::new(
172188 "cursor-writer".to_string(),
-82
src/identity.rs
···11use atproto_identity::model::Document;
22use atproto_identity::resolve::IdentityResolver;
33-use atproto_identity::storage::DidDocumentStorage;
43use chrono::Utc;
54use std::sync::Arc;
65···6463 Ok(())
6564 }
6665}
6767-6868-/// A variant that works with DidDocumentStorage instead of IdentityStorage
6969-pub(crate) struct CachingDidDocumentResolver<T: DidDocumentStorage + ?Sized> {
7070- /// The underlying identity resolver to use when cache misses occur
7171- resolver: IdentityResolver,
7272- /// The storage implementation to use for caching
7373- storage: Arc<T>,
7474-}
7575-7676-impl<T: DidDocumentStorage + ?Sized> CachingDidDocumentResolver<T> {
7777- /// Create a new caching identity resolver with the given resolver and storage.
7878- pub(crate) fn new(resolver: IdentityResolver, storage: Arc<T>) -> Self {
7979- Self { resolver, storage }
8080- }
8181-8282- /// Resolve a DID to a Document, using the cache when possible.
8383- pub(crate) async fn resolve(&self, did: &str) -> Result<Document> {
8484- // First, try to get the document from storage
8585- if let Some(document) = self.storage.get_document_by_did(did).await? {
8686- return Ok(document);
8787- }
8888-8989- // If not in storage, resolve using the underlying resolver
9090- let document = self.resolver.resolve(did).await?;
9191-9292- // Store the resolved document for future lookups
9393- self.storage.store_document(document.clone()).await?;
9494-9595- Ok(document)
9696- }
9797-9898- /// Resolve a handle to a Document, using the cache when possible.
9999- pub(crate) async fn resolve_handle(&self, handle: &str) -> Result<Document> {
100100- // For handle resolution, we need to resolve first since DidDocumentStorage
101101- // doesn't have a get_by_handle method
102102- let document = self.resolver.resolve(handle).await?;
103103-104104- // Store the resolved document for future lookups
105105- self.storage.store_document(document.clone()).await?;
106106-107107- Ok(document)
108108- }
109109-}
110110-111111-#[cfg(test)]
112112-mod tests {
113113- use super::*;
114114- use crate::storage::sqlite::SqliteStorage;
115115- use atproto_identity::resolve::{InnerIdentityResolver, create_resolver};
116116- use sqlx::SqlitePool;
117117- use std::sync::Arc;
118118-119119- #[tokio::test]
120120- async fn test_caching_identity_resolver() -> Result<()> {
121121- // Create an in-memory SQLite database for testing
122122- let pool = SqlitePool::connect("sqlite::memory:").await?;
123123- let storage = Arc::new(SqliteStorage::new(pool));
124124-125125- // Run migrations using the Storage trait
126126- use crate::storage::Storage;
127127- storage.migrate().await?;
128128-129129- // Create a mock resolver (this would normally resolve from the network)
130130- let dns_resolver = create_resolver(&[]);
131131- let http_client = reqwest::Client::new();
132132- let inner_resolver = InnerIdentityResolver {
133133- dns_resolver,
134134- http_client,
135135- plc_hostname: "plc.directory".to_string(),
136136- };
137137- let resolver = IdentityResolver(Arc::new(inner_resolver));
138138-139139- // Create the caching resolver
140140- let caching_resolver = CachingIdentityResolver::new(resolver, storage.clone());
141141-142142- // Test would go here - this is just a skeleton since we'd need real DIDs
143143- // and network access to properly test the resolution
144144-145145- Ok(())
146146- }
147147-}
+17
src/lexicon.rs
···11use chrono::{DateTime, Utc};
22use serde::Deserialize;
3344+/// A blob record containing binary data metadata.
45#[derive(Debug, Clone, Deserialize)]
56pub struct BlobRecord {
77+ /// The record type
68 #[serde(rename = "$type")]
79 pub r#type: String,
8101111+ /// MIME type of the blob
912 #[serde(rename = "mimeType")]
1013 pub mime_type: String,
1414+ /// Size of the blob in bytes
1115 pub size: i64,
12161717+ /// Reference to the blob content
1318 #[serde(rename = "ref")]
1419 pub r#ref: BlobRef,
1520}
16212222+/// A reference to blob content via CID link.
1723#[derive(Debug, Clone, Deserialize)]
1824pub struct BlobRef {
2525+ /// The CID link to the blob content
1926 #[serde(rename = "$link")]
2027 pub link: String,
2128}
22293030+/// An attachment to a blog post.
2331#[derive(Debug, Clone, Deserialize)]
2432pub struct PostAttachment {
3333+ /// The attachment type
2534 #[serde(rename = "$type")]
2635 pub r#type: String,
27363737+ /// The blob content of the attachment
2838 pub content: BlobRecord,
2939}
30404141+/// A blog post record from the ATProtocol lexicon.
3142#[derive(Debug, Clone, Deserialize)]
3243pub struct PostRecord {
4444+ /// The record type
3345 #[serde(rename = "$type")]
3446 pub r#type: String,
35474848+ /// Title of the blog post
3649 pub title: String,
5050+ /// Main content of the blog post
3751 pub content: BlobRecord,
38525353+ /// Publication timestamp
3954 #[serde(rename = "publishedAt")]
4055 pub published_at: DateTime<Utc>,
41565757+ /// List of attachments to the post
4258 #[serde(default = "empty_attachments")]
4359 pub attachments: Vec<PostAttachment>,
44606161+ /// Languages used in the post
4562 pub langs: Vec<String>,
4663}
4764
+34
src/lib.rs
···11+//! # Blahg - ATProtocol Blog AppView
22+//!
33+//! Blahg is a Rust-based ATProtocol AppView that renders personal blog content.
44+//! It consumes ATProtocol records to create a blog-like experience by discovering
55+//! and displaying relevant posts.
66+//!
77+//! ## Architecture
88+//!
99+//! - **ATProtocol Integration**: Uses the atproto ecosystem crates for identity resolution and record processing
1010+//! - **Storage Layer**: Flexible storage backends including SQLite, PostgreSQL, and content storage
1111+//! - **Rendering**: Markdown rendering with syntax highlighting
1212+//! - **Event Processing**: Real-time consumption of ATProtocol events via jetstream
1313+//!
1414+//! ## Key Components
1515+//!
1616+//! - [`config`] - Configuration management
1717+//! - [`consumer`] - ATProtocol event consumption
1818+//! - [`process`] - Event processing and record handling
1919+//! - [`storage`] - Data persistence layer
2020+//! - [`render`] - Markdown rendering
2121+//! - [`http`] - Web server and API endpoints
2222+//! - [`identity`] - ATProtocol identity resolution
2323+//! - [`lexicon`] - ATProtocol record type definitions
2424+125#![warn(missing_docs)]
2262727+/// Configuration management for the Blahg application.
328pub mod config;
2929+/// ATProtocol event consumption and handling.
430pub mod consumer;
3131+/// Error types and handling for the Blahg application.
532pub mod errors;
3333+/// HTTP server and web API endpoints.
634pub mod http;
3535+/// ATProtocol identity resolution and caching.
736pub mod identity;
3737+/// ATProtocol record type definitions and lexicon.
838pub mod lexicon;
3939+/// Event processing and record handling logic.
940pub mod process;
4141+/// Markdown rendering with syntax highlighting.
1042pub mod render;
4343+/// Data persistence layer with multiple backend support.
1144pub mod storage;
4545+/// Template management and rendering.
1246pub mod templates;
+4-13
src/process.rs
···2828#[derive(Debug, Deserialize, Serialize, Default)]
2929struct StrongRef {
3030 #[serde(rename = "$type")]
3131- pub type_: Option<String>,
3232- pub uri: String,
3333- pub cid: String,
3131+ type_: Option<String>,
3232+ uri: String,
3333+ cid: String,
3434}
35353636/// app.bsky.feed.post record structure
3737#[derive(Debug, Deserialize)]
3838struct FeedPostRecord {
3939 #[serde(rename = "$type")]
4040- pub type_: Option<String>,
4040+ type_: Option<String>,
41414242 #[serde(default)]
4343 facets: Vec<Facet>,
···409409 }
410410411411 Ok(())
412412- }
413413-414414- /// Check if an AT-URI references a post from a known author
415415- fn is_known_author_post(&self, uri: &str) -> bool {
416416- if let Ok(aturi) = ATURI::from_str(uri) {
417417- return aturi.collection == "tools.smokesignal.blahg.content.post"
418418- && self.config.author == aturi.authority;
419419- }
420420- false
421412 }
422413423414 /// Creates a post prefix lookup hashmap with external URL prefixes mapped to AT-URIs
···55use serde_json::Value;
66use std::collections::HashMap;
7788+/// An ATProtocol identity record.
89#[derive(Clone, Serialize, Deserialize, sqlx::FromRow)]
910pub struct Identity {
1010- pub did: String,
1111- pub handle: String,
1212- pub record: Value,
1313- pub created_at: DateTime<Utc>,
1414- pub updated_at: DateTime<Utc>,
1111+ /// The decentralized identifier (DID) of the identity
1212+ pub (crate) did: String,
1313+ /// The handle associated with the identity
1414+ pub (crate) handle: String,
1515+ /// The identity document as JSON
1616+ pub (crate) record: Value,
1717+ /// When the identity was first created
1818+ pub (crate) created_at: DateTime<Utc>,
1919+ /// When the identity was last updated
2020+ pub (crate) updated_at: DateTime<Utc>,
1521}
16221717-// tools.smokesignal.blahg.content.post
2323+/// A blog post record from the `tools.smokesignal.blahg.content.post` collection.
1824#[derive(Clone, Serialize, Deserialize, sqlx::FromRow)]
1925pub struct Post {
2626+ /// The AT-URI of the post record
2027 pub aturi: String,
2828+ /// The content identifier (CID) of the post
2129 pub cid: String,
3030+ /// The title of the blog post
2231 pub title: String,
3232+ /// The URL slug for the post
2333 pub slug: String,
3434+ /// The markdown content of the post
2435 pub content: String,
3636+ /// The record key within the collection
2537 pub record_key: String,
3838+ /// When the post was first created
2639 pub created_at: DateTime<Utc>,
4040+ /// When the post was last updated
2741 pub updated_at: DateTime<Utc>,
4242+ /// The original ATProtocol record as JSON
2843 pub record: Value,
2944}
30454646+/// A reference to a blog post from another ATProtocol record.
3147#[derive(Clone, Serialize, Deserialize, sqlx::FromRow)]
3248pub struct PostReference {
4949+ /// The AT-URI of the reference record
3350 pub aturi: String,
5151+ /// The content identifier (CID) of the reference
3452 pub cid: String,
5353+ /// The DID of the author who created the reference
3554 pub did: String,
5555+ /// The collection containing the reference
3656 pub collection: String,
5757+ /// The AT-URI of the post being referenced
3758 pub post_aturi: String,
5959+ /// When the reference was discovered
3860 pub discovered_at: DateTime<Utc>,
6161+ /// The original reference record as JSON
3962 pub record: Value,
4063}
41646565+/// Trait for storing and retrieving blog posts and post references.
4266#[async_trait]
4367pub trait PostStorage: Send + Sync {
6868+ /// Insert or update a blog post.
4469 async fn upsert_post(&self, post: &Post) -> Result<()>;
45707171+ /// Get a blog post by its AT-URI.
4672 async fn get_post(&self, aturi: &str) -> Result<Option<Post>>;
47737474+ /// Get all blog posts.
4875 async fn get_posts(&self) -> Result<Vec<Post>>;
49767777+ /// Delete a blog post by its AT-URI.
5078 async fn delete_post(&self, aturi: &str) -> Result<Option<Post>>;
51798080+ /// Insert or update a post reference.
5281 async fn upsert_post_reference(&self, post_reference: &PostReference) -> Result<bool>;
53828383+ /// Delete a post reference by its AT-URI.
5484 async fn delete_post_reference(&self, aturi: &str) -> Result<()>;
55858686+ /// Get the count of references to a post grouped by collection.
5687 async fn get_post_reference_count(&self, post_aturi: &str) -> Result<HashMap<String, i64>>;
57888989+ /// Get all references to a specific post.
5890 async fn get_post_references_for_post(&self, post_aturi: &str) -> Result<Vec<PostReference>>;
59919292+ /// Get references to a post from a specific collection.
6093 async fn get_post_references_for_post_for_collection(
6194 &self,
6295 post_aturi: &str,
···6497 ) -> Result<Vec<PostReference>>;
6598}
6699100100+/// Trait for storing and retrieving ATProtocol identities.
67101#[async_trait]
68102pub trait IdentityStorage: Send + Sync {
103103+ /// Insert or update an identity record.
69104 async fn upsert_identity(&self, identity: &Identity) -> Result<()>;
70105106106+ /// Get an identity by its DID.
71107 async fn get_identity_by_did(&self, did: &str) -> Result<Option<Identity>>;
72108109109+ /// Get an identity by its handle.
73110 async fn get_identity_by_handle(&self, handle: &str) -> Result<Option<Identity>>;
74111112112+ /// Delete an identity by its AT-URI.
75113 async fn delete_identity(&self, aturi: &str) -> Result<Option<Identity>>;
76114}
77115116116+/// Trait for storing and retrieving content by CID.
78117#[async_trait]
79118pub trait ContentStorage: Send + Sync {
119119+ /// Check if content exists for a given CID.
80120 async fn content_exists(&self, cid: &str) -> Result<bool>;
81121122122+ /// Write content data for a given CID.
82123 async fn write_content(&self, cid: &str, data: &[u8]) -> Result<()>;
83124125125+ /// Read content data for a given CID.
84126 async fn read_content(&self, cid: &str) -> Result<Vec<u8>>;
85127}
86128129129+/// Combined trait for all storage operations.
87130#[async_trait]
88131pub trait Storage: PostStorage + IdentityStorage + Send + Sync {
132132+ /// Run database migrations.
89133 async fn migrate(&self) -> Result<()>;
90134}
91135136136+/// Cached storage implementations.
92137pub mod cached;
138138+/// Content storage implementations.
93139pub mod content;
140140+/// PostgreSQL storage implementation.
94141pub mod postgres;
142142+/// SQLite storage implementation.
95143#[cfg(feature = "sqlite")]
96144pub mod sqlite;
97145