use jiff::tz::TimeZone; use jiff::Timestamp; use pleroma::Activities; use serde::{Deserialize, Serialize}; use std::sync::OnceLock; use std::{ collections::HashMap, env, fmt, fs::File, io::{self, BufReader, BufWriter}, path::{Path, PathBuf}, process::ExitCode, }; use url::Url; mod pleroma; type BoxError = Box<dyn std::error::Error>; type Mappings = HashMap<String, Option<Url>>; static TZ: OnceLock<TimeZone> = OnceLock::new(); static MAPPINGS: OnceLock<Mappings> = OnceLock::new(); fn main() -> ExitCode { let Some(path) = env::args_os().skip(1).next().map(PathBuf::from) else { eprintln!("Usage: pleroma-archive path/to/pleroma-archive"); return ExitCode::FAILURE; }; let timezone = TimeZone::system(); TZ.set(timezone).unwrap(); match try_main(&path) { Ok(()) => ExitCode::SUCCESS, Err(err) => { eprintln!("Error: {err}"); ExitCode::FAILURE } } } fn try_main(path: &Path) -> Result<(), BoxError> { let actor_path = path.join("actor.json"); let outbox_path = path.join("outbox.json"); let mappings_path = path.join("mappings.json"); let file = BufReader::new(File::open(&actor_path)?); let actor: pleroma::Actor = serde_json::from_reader(file)?; let file = BufReader::new(File::open(&outbox_path)?); let activities: Activities = serde_json::from_reader(file)?; // Load mappings of ids to public URLs let mut mappings: Mappings = match File::open(&mappings_path) { Ok(file) => serde_json::from_reader(BufReader::new(file))?, Err(err) if err.kind() == io::ErrorKind::NotFound => HashMap::new(), Err(err) => return Err(err.into()), }; let agent = ureq::AgentBuilder::new().redirects(0).build(); let mut posts = Vec::with_capacity(activities.ordered_items.len()); for item in &activities.ordered_items { match &item.object { pleroma::activity::ObjectUnion::ObjectClass(activity) => { let id: Url = item.id.parse()?; if !mappings.contains_key(id.as_str()) { let response = match agent.head(id.as_str()).call() { Ok(res) => res, Err(ureq::Error::Status(status, _res)) => { eprintln!("expected 3xx response, got {} for {}", status, id); mappings.insert(item.id.clone(), None); continue; } Err(err) => return Err(err.into()), }; if !(300..400).contains(&response.status()) { eprintln!( "expected 3xx response, got {} for {}", response.status(), id ); mappings.insert(item.id.clone(), None); continue; } let Some(location) = response.header("location") else { return Err("expected a Location header, but it's missing".into()); }; let url = id.join(location)?; mappings.insert(item.id.clone(), Some(url)); } posts.push(activity); } pleroma::activity::ObjectUnion::String(s) => { eprintln!("ObjectUnion::String: {s}"); // TODO } } } let mappings_writer = BufWriter::new(File::create(&mappings_path)?); serde_json::to_writer_pretty(mappings_writer, &mappings)?; MAPPINGS.set(mappings).unwrap(); // dbg!(&posts); println!( "{}", Page { title: "Pleroma Archive", actor: &actor, activities: &posts, } ); Ok(()) } markup::define! { Page<'a>(title: &'a str, actor: &'a pleroma::Actor, activities: &'a [&'a pleroma::Activity]) { @markup::doctype() html { head { meta[charset="utf-8"]; meta[name="viewport", content="width=device-width, initial-scale=1"]; title { @title } link[rel="stylesheet", type="text/css", href="style.css"]; } body { @Header { title, actor } main { @for activity in activities.iter().rev() { @Activity { actor, activity } } } @Footer { } } } } Header<'a>(title: &'a str, actor: &'a pleroma::Actor) { header { h1 { @title } p { "This is a static archive of " @actor.username() } p { @markup::raw(&actor.summary) } } } Footer() { footer { "Generated by " a[href="https://forge.wezm.net/wezm/pleroma-archive"] { "pleroma-archive" } } } Activity<'a>(actor: &'a pleroma::Actor, activity: &'a pleroma::Activity) { div[class=activity_class(&activity.object_type)] { @Actor { actor } div[class="activity-content"] { a[href=MAPPINGS.get().unwrap().get(&activity.id).and_then(|url| url.as_ref().map(|url| url.as_str()))] { time[datetime=&activity.published] { @activity.human_published() } } @if let Some(in_reply_to) = &activity.in_reply_to { a[href=in_reply_to, class="activity-reply-to"] { "↩ reply to" } " " } @markup::raw(&activity.content) } } hr; } Actor<'a>(actor: &'a pleroma::Actor) { @if let Some(icon) = &actor.icon { @if icon.icon_type == "Image" { img[src=&icon.url, alt=&actor.preferred_username, class="actor-icon"]; } } } } fn activity_class(object_type: &pleroma::activity::OneOfType) -> &'static str { match object_type { pleroma::activity::OneOfType::Note => "activity activity-note", pleroma::activity::OneOfType::Question => "activity activity-question", } } impl pleroma::Activity { fn human_published(&self) -> String { let published = self .published .parse() .map(|timestamp: Timestamp| timestamp.to_zoned(TZ.get().unwrap().clone())) .expect("invalid published value"); published.strftime("%d %b %Y").to_string() } } impl pleroma::Actor { fn username(&self) -> &str { self.webfinger .strip_prefix("acct:") .unwrap_or(&self.webfinger) } }