forked from DevComp/ssh-portfolio
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:
parent
21de453415
commit
27eaf50448
9 changed files with 1684 additions and 74 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,2 +1,6 @@
|
||||||
/target
|
/target
|
||||||
.data/*.log
|
.data/*.log
|
||||||
|
|
||||||
|
src/atproto/*
|
||||||
|
!src/atproto/lexicons
|
||||||
|
!src/atproto/mod.rs
|
1404
Cargo.lock
generated
1404
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
19
Cargo.toml
19
Cargo.toml
|
@ -6,9 +6,25 @@ description = "no"
|
||||||
authors = ["Erica Marigold <hi@devcomp.xyz>"]
|
authors = ["Erica Marigold <hi@devcomp.xyz>"]
|
||||||
build = "build.rs"
|
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]
|
[dependencies]
|
||||||
anyhow = "1.0.90"
|
anyhow = "1.0.90"
|
||||||
async-trait = "0.1.85"
|
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"
|
better-panic = "0.3.0"
|
||||||
bstr = "1.11.3"
|
bstr = "1.11.3"
|
||||||
clap = { version = "4.5.20", features = [
|
clap = { version = "4.5.20", features = [
|
||||||
|
@ -28,11 +44,13 @@ figlet-rs = "0.1.5"
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
human-panic = "2.0.2"
|
human-panic = "2.0.2"
|
||||||
indoc = "2.0.5"
|
indoc = "2.0.5"
|
||||||
|
ipld-core = { version = "0.4.2", optional = true }
|
||||||
json5 = "0.4.1"
|
json5 = "0.4.1"
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
libc = "0.2.161"
|
libc = "0.2.161"
|
||||||
pretty_assertions = "1.4.1"
|
pretty_assertions = "1.4.1"
|
||||||
ratatui = { version = "0.29.0", features = ["serde", "macros"] }
|
ratatui = { version = "0.29.0", features = ["serde", "macros"] }
|
||||||
|
reqwest = { version = "0.12", features = ["rustls-tls"], optional = true }
|
||||||
russh = "0.49.2"
|
russh = "0.49.2"
|
||||||
serde = { version = "1.0.211", features = ["derive"] }
|
serde = { version = "1.0.211", features = ["derive"] }
|
||||||
serde_json = "1.0.132"
|
serde_json = "1.0.132"
|
||||||
|
@ -47,5 +65,6 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
anyhow = "1.0.90"
|
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"] }
|
ssh-key = { version = "0.6.7", features = ["getrandom", "crypto"] }
|
||||||
vergen-gix = { version = "1.0.2", features = ["build", "cargo"] }
|
vergen-gix = { version = "1.0.2", features = ["build", "cargo"] }
|
||||||
|
|
50
build.rs
50
build.rs
|
@ -4,39 +4,49 @@ use anyhow::Result;
|
||||||
use ssh_key::{rand_core, Algorithm, EcdsaCurve, LineEnding, PrivateKey};
|
use ssh_key::{rand_core, Algorithm, EcdsaCurve, LineEnding, PrivateKey};
|
||||||
use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder};
|
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)] = &[
|
const SSH_KEY_ALGOS: &[(&'static str, Algorithm)] = &[
|
||||||
("rsa.pem", Algorithm::Rsa { hash: None }),
|
("rsa.pem", Algorithm::Rsa { hash: None }),
|
||||||
("ed25519.pem", Algorithm::Ed25519),
|
("ed25519.pem", Algorithm::Ed25519),
|
||||||
("ecdsa.pem", Algorithm::Ecdsa {
|
(
|
||||||
curve: EcdsaCurve::NistP256,
|
"ecdsa.pem",
|
||||||
}),
|
Algorithm::Ecdsa {
|
||||||
|
curve: EcdsaCurve::NistP256,
|
||||||
|
},
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
println!("cargo:rerun-if-changed=build.rs");
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
println!("cargo:rerun-if-changed=src/atproto/lexicons");
|
||||||
|
|
||||||
// Generate openSSH host keys
|
// 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());
|
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||||
for (file_name, key_res) in keys {
|
let mut rng = rand_core::OsRng::default();
|
||||||
if let Ok(ref key) = key_res {
|
for (file_name, algo) in SSH_KEY_ALGOS {
|
||||||
let path = out_dir.join(file_name);
|
let path = out_dir.join(file_name);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
println!("cargo:warning=Skipping existing host key: {:?}", path.file_stem());
|
println!(
|
||||||
continue;
|
"cargo:warning=Skipping existing host key: {:?}",
|
||||||
}
|
path.file_stem().unwrap()
|
||||||
|
);
|
||||||
key.write_openssh_file(&path, LineEnding::default())?;
|
continue;
|
||||||
} else {
|
|
||||||
println!("cargo:warning=Failed to generate host key: {:?}", key_res);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let key = PrivateKey::random(&mut rng, algo.to_owned()).map_err(anyhow::Error::from)?;
|
||||||
|
key.write_openssh_file(&path, LineEnding::default())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Emit the build information
|
||||||
let build = BuildBuilder::all_build()?;
|
let build = BuildBuilder::all_build()?;
|
||||||
let gix = GixBuilder::all_git()?;
|
let gix = GixBuilder::all_git()?;
|
||||||
|
|
74
src/atproto/lexicons/WhtwndBlogDefs.json
Normal file
74
src/atproto/lexicons/WhtwndBlogDefs.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
66
src/atproto/lexicons/WhtwndBlogEntry.json
Normal file
66
src/atproto/lexicons/WhtwndBlogEntry.json
Normal 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
110
src/atproto/mod.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,7 +9,7 @@ use ratatui::{prelude::*, widgets::*};
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
use super::Component;
|
use super::Component;
|
||||||
use crate::{action::Action, config::Config};
|
use crate::{action::Action, atproto, config::Config};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Content {
|
pub struct Content {
|
||||||
|
@ -176,8 +176,13 @@ impl Content {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate the content for the "Blog" tab
|
/// Generate the content for the "Blog" tab
|
||||||
fn blog_content(&self) -> Vec<Line<'static>> {
|
#[cfg(feature = "blog")]
|
||||||
vec![Line::from("coming soon! :^)")]
|
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) {
|
let content = match self.selected_tab.load(Ordering::Relaxed) {
|
||||||
0 => self.about_content(area)?,
|
0 => self.about_content(area)?,
|
||||||
1 => self.projects_content(),
|
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!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -12,8 +12,15 @@ use russh::{
|
||||||
use ssh::SshServer;
|
use ssh::SshServer;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
#[cfg(feature = "blog")]
|
||||||
|
pub(crate) use atproto::com;
|
||||||
|
#[cfg(feature = "blog")]
|
||||||
|
pub(crate) use atrium_api::*;
|
||||||
|
|
||||||
mod action;
|
mod action;
|
||||||
mod app;
|
mod app;
|
||||||
|
#[cfg(feature = "blog")]
|
||||||
|
pub(crate) mod atproto;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod components;
|
mod components;
|
||||||
mod config;
|
mod config;
|
||||||
|
|
Loading…
Add table
Reference in a new issue