feat: include ATProto powered blog using whitewind lexicons

Includes a blog system which fetches whitewind blog posts stored on an
ATProto PDS:

* Optional feature to enable the blog system
* Updated build script to generate client from lexicons
* Fetching system along with in-memory caching and data validation
This commit is contained in:
Erica Marigold 2025-08-11 07:56:46 +01:00
parent 21de453415
commit 27eaf50448
Signed by: DevComp
SSH key fingerprint: SHA256:jD3oMT4WL3WHPJQbrjC3l5feNCnkv7ndW8nYaHX5wFw
9 changed files with 1684 additions and 74 deletions

4
.gitignore vendored
View file

@ -1,2 +1,6 @@
/target
.data/*.log
src/atproto/*
!src/atproto/lexicons
!src/atproto/mod.rs

1404
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,9 +6,25 @@ description = "no"
authors = ["Erica Marigold <hi@devcomp.xyz>"]
build = "build.rs"
[features]
# TODO: CLI feature
default = ["blog"]
blog = [
"dep:atrium-api",
"dep:atrium-xrpc",
"dep:atrium-xrpc-client",
"dep:atrium-common",
"dep:reqwest",
"dep:ipld-core",
]
[dependencies]
anyhow = "1.0.90"
async-trait = "0.1.85"
atrium-api = { version = "0.25.4", optional = true }
atrium-common = { version = "0.1.2", optional = true }
atrium-xrpc = { version = "0.12.3", optional = true }
atrium-xrpc-client = { version = "0.5.14", optional = true }
better-panic = "0.3.0"
bstr = "1.11.3"
clap = { version = "4.5.20", features = [
@ -28,11 +44,13 @@ figlet-rs = "0.1.5"
futures = "0.3.31"
human-panic = "2.0.2"
indoc = "2.0.5"
ipld-core = { version = "0.4.2", optional = true }
json5 = "0.4.1"
lazy_static = "1.5.0"
libc = "0.2.161"
pretty_assertions = "1.4.1"
ratatui = { version = "0.29.0", features = ["serde", "macros"] }
reqwest = { version = "0.12", features = ["rustls-tls"], optional = true }
russh = "0.49.2"
serde = { version = "1.0.211", features = ["derive"] }
serde_json = "1.0.132"
@ -47,5 +65,6 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] }
[build-dependencies]
anyhow = "1.0.90"
atrium-codegen = { git = "https://github.com/atrium-rs/atrium.git", rev = "ccc0213" }
ssh-key = { version = "0.6.7", features = ["getrandom", "crypto"] }
vergen-gix = { version = "1.0.2", features = ["build", "cargo"] }

View file

@ -4,39 +4,49 @@ use anyhow::Result;
use ssh_key::{rand_core, Algorithm, EcdsaCurve, LineEnding, PrivateKey};
use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder};
const ATPROTO_LEXICON_DIR: &str = "src/atproto/lexicons";
const ATPROTO_CLIENT_DIR: &str = "src/atproto";
const SSH_KEY_ALGOS: &[(&'static str, Algorithm)] = &[
("rsa.pem", Algorithm::Rsa { hash: None }),
("ed25519.pem", Algorithm::Ed25519),
("ecdsa.pem", Algorithm::Ecdsa {
(
"ecdsa.pem",
Algorithm::Ecdsa {
curve: EcdsaCurve::NistP256,
}),
},
),
];
fn main() -> Result<()> {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=src/atproto/lexicons");
// Generate openSSH host keys
let mut rng = rand_core::OsRng::default();
let keys = SSH_KEY_ALGOS
.iter()
.map(|(file_name, algo)| (*file_name, PrivateKey::random(&mut rng, algo.to_owned()).map_err(anyhow::Error::from)))
.collect::<Vec<(&str, Result<PrivateKey>)>>();
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
for (file_name, key_res) in keys {
if let Ok(ref key) = key_res {
let mut rng = rand_core::OsRng::default();
for (file_name, algo) in SSH_KEY_ALGOS {
let path = out_dir.join(file_name);
if path.exists() {
println!("cargo:warning=Skipping existing host key: {:?}", path.file_stem());
println!(
"cargo:warning=Skipping existing host key: {:?}",
path.file_stem().unwrap()
);
continue;
}
let key = PrivateKey::random(&mut rng, algo.to_owned()).map_err(anyhow::Error::from)?;
key.write_openssh_file(&path, LineEnding::default())?;
} else {
println!("cargo:warning=Failed to generate host key: {:?}", key_res);
}
}
// Generate ATProto client with lexicon validation
#[cfg(feature = "blog")]
atrium_codegen::genapi(
ATPROTO_LEXICON_DIR,
ATPROTO_CLIENT_DIR,
&[("com.whtwnd", Some("blog"))],
)
.unwrap();
// Emit the build information
let build = BuildBuilder::all_build()?;
let gix = GixBuilder::all_git()?;

View file

@ -0,0 +1,74 @@
{
"lexicon": 1,
"id": "com.whtwnd.blog.defs",
"defs": {
"blogEntry": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"type": "string",
"maxLength": 100000
},
"createdAt": {
"type": "string",
"format": "datetime"
}
}
},
"comment": {
"type": "object",
"required": [
"content",
"entryUri"
],
"properties": {
"content": {
"type": "string",
"maxLength": 1000
},
"entryUri": {
"type": "string",
"format": "at-uri"
}
}
},
"ogp": {
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"format": "uri"
},
"width": {
"type": "integer"
},
"height": {
"type": "integer"
}
}
},
"blobMetadata": {
"type": "object",
"required": [
"blobref"
],
"properties": {
"blobref": {
"type": "blob",
"accept": [
"*/*"
]
},
"name": {
"type": "string"
}
}
}
}
}

View file

@ -0,0 +1,66 @@
{
"lexicon": 1,
"id": "com.whtwnd.blog.entry",
"defs": {
"main": {
"type": "record",
"description": "A declaration of a post.",
"key": "tid",
"record": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"type": "string",
"maxLength": 100000
},
"createdAt": {
"type": "string",
"format": "datetime"
},
"title": {
"type": "string",
"maxLength": 1000
},
"subtitle": {
"type": "string",
"maxLength": 1000
},
"ogp": {
"type": "ref",
"ref": "com.whtwnd.blog.defs#ogp"
},
"theme": {
"type": "string",
"enum": [
"github-light"
]
},
"blobs": {
"type": "array",
"items": {
"type": "ref",
"ref": "com.whtwnd.blog.defs#blobMetadata"
}
},
"isDraft": {
"type": "boolean",
"description": "(DEPRECATED) Marks this entry as draft to tell AppViews not to show it to anyone except for the author"
},
"visibility": {
"type": "string",
"enum": [
"public",
"url",
"author"
],
"default": "public",
"description": "Tells the visibility of the article to AppView."
}
}
}
}
}
}

110
src/atproto/mod.rs Normal file
View file

@ -0,0 +1,110 @@
#[cfg(feature = "blog")]
pub mod com;
#[cfg(feature = "blog")]
pub mod blog {
use std::str::FromStr as _;
use atrium_api::com::atproto::server::create_session::OutputData as SessionOutputData;
use atrium_api::{
agent::{
atp_agent::{store::MemorySessionStore, CredentialSession},
Agent,
},
com::atproto::repo::list_records,
types::{
string::{AtIdentifier, Handle},
Collection as _, Object, Unknown,
},
};
use atrium_common::store::{memory::MemoryStore, Store};
use atrium_xrpc_client::reqwest::ReqwestClient;
use color_eyre::{eyre::eyre, Result};
use ipld_core::ipld::Ipld;
use lazy_static::lazy_static;
use tokio::time::{Duration, Instant};
use tracing::instrument;
use super::*;
const CACHE_INVALIDATION_PERIOD: Duration = Duration::from_secs(30 * 60); // 30 minutes
lazy_static! {
static ref POSTS_CACHE_STORE: MemoryStore<usize, (Instant, com::whtwnd::blog::entry::Record)> =
MemoryStore::default();
static ref AGENT: Agent<CredentialSession<MemoryStore<(), Object<SessionOutputData>>, ReqwestClient>> =
Agent::new(CredentialSession::new(
ReqwestClient::new("https://bsky.social"),
MemorySessionStore::default(),
));
}
#[instrument(level = "debug")]
pub async fn get_all_posts() -> Result<Vec<com::whtwnd::blog::entry::Record>> {
let mut i = 0;
let mut posts = Vec::new();
while let Some((cache_creation_time, post)) = POSTS_CACHE_STORE.get(&i).await? {
if cache_creation_time.elapsed() > CACHE_INVALIDATION_PERIOD {
tracing::info!("Cache for post #{} is stale, fetching new posts", i);
POSTS_CACHE_STORE.clear().await?;
return fetch_posts_into_cache().await;
}
posts.push(post);
i += 1;
}
if posts.is_empty() {
tracing::info!("No blog posts found in cache, fetching from ATProto");
return fetch_posts_into_cache().await;
}
Ok(posts)
}
#[instrument(level = "trace")]
async fn fetch_posts_into_cache() -> Result<Vec<com::whtwnd::blog::entry::Record>> {
let records = &AGENT
.api
.com
.atproto
.repo
.list_records(Object::from(list_records::Parameters {
extra_data: Ipld::Null,
data: list_records::ParametersData {
collection: com::whtwnd::blog::Entry::nsid(),
cursor: None,
limit: None,
reverse: None,
repo: AtIdentifier::Handle(
Handle::from_str("devcomp.xyz")
.map_err(|_| eyre!("Invalid repo handle"))?,
),
},
}))
.await?
.records;
let posts = records
.iter()
.map(|elem| {
if let Unknown::Object(btree_map) = &elem.data.value {
let ser = serde_json::to_string(&btree_map)?;
let des = serde_json::from_str::<com::whtwnd::blog::entry::Record>(&ser)?;
return Ok(des);
}
Err(eyre!("Did not get posts back from atproto"))
})
.collect::<Result<Vec<com::whtwnd::blog::entry::Record>>>()?;
for (i, post) in posts.iter().enumerate() {
POSTS_CACHE_STORE
.set(i, (Instant::now(), post.clone()))
.await?;
}
Ok(posts)
}
}

View file

@ -9,7 +9,7 @@ use ratatui::{prelude::*, widgets::*};
use tokio::sync::mpsc::UnboundedSender;
use super::Component;
use crate::{action::Action, config::Config};
use crate::{action::Action, atproto, config::Config};
#[derive(Default)]
pub struct Content {
@ -176,8 +176,13 @@ impl Content {
}
/// Generate the content for the "Blog" tab
fn blog_content(&self) -> Vec<Line<'static>> {
vec![Line::from("coming soon! :^)")]
#[cfg(feature = "blog")]
async fn blog_content(&self) -> Result<Vec<Line<'static>>> {
Ok(atproto::blog::get_all_posts()
.await?
.iter()
.map(|post| Line::from(post.title.clone().unwrap_or("<unknown>".to_string())))
.collect())
}
}
@ -205,7 +210,16 @@ impl Component for Content {
let content = match self.selected_tab.load(Ordering::Relaxed) {
0 => self.about_content(area)?,
1 => self.projects_content(),
2 => self.blog_content(),
2 => {
if cfg!(feature = "blog") {
let rt = tokio::runtime::Handle::current();
rt.block_on(self.blog_content())?
} else {
vec![Line::from(
"Blog feature is disabled. Enable the `blog` feature to view this tab.",
)]
}
}
_ => unreachable!(),
};

View file

@ -12,8 +12,15 @@ use russh::{
use ssh::SshServer;
use tokio::net::TcpListener;
#[cfg(feature = "blog")]
pub(crate) use atproto::com;
#[cfg(feature = "blog")]
pub(crate) use atrium_api::*;
mod action;
mod app;
#[cfg(feature = "blog")]
pub(crate) mod atproto;
mod cli;
mod components;
mod config;