use jiff::tz::TimeZone; use jiff::Timestamp; use pleroma::Activities; use std::ffi::OsStr; use std::sync::OnceLock; use std::{ collections::HashMap, env, fs, 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>>; const STYLE: &str = include_str!("../style.css"); static TZ: OnceLock<TimeZone> = OnceLock::new(); static MAPPINGS: OnceLock<Mappings> = OnceLock::new(); fn main() -> ExitCode { let args = env::args_os().skip(1).collect::<Vec<_>>(); let (archive_path, output_path) = match args.as_slice() { [archive, output] => (Path::new(archive), Path::new(output)), _ => { eprintln!("Usage: pleroma-archive path/to/pleroma-archive output/path"); return ExitCode::FAILURE; } }; let timezone = TimeZone::system(); TZ.set(timezone).unwrap(); match try_main(archive_path, output_path) { Ok(()) => ExitCode::SUCCESS, Err(err) => { eprintln!("Error: {err}"); ExitCode::FAILURE } } } fn try_main(path: &Path, output_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()), }; // Ensure output path exists, and write out stylesheet fs::create_dir_all(output_path)?; let style_path = output_path.join("style.css"); fs::write(&style_path, STYLE)?; let agent = ureq::AgentBuilder::new().redirects(0).build(); // Process posts let mut posts = Vec::with_capacity(activities.ordered_items.len()); for item in &activities.ordered_items { if item.direct_message { continue; } 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(); // Generate index.html let index_html = Layout { title: "Pleroma Archive", body: Index { actor: &actor, activities: &posts, }, actor: &actor, } .to_string(); let index_path = output_path.join("index.html"); println!("Writing {}", index_path.display()); fs::write(&index_path, index_html.as_bytes())?; // Generate individual post pages for post in posts { let Some(Some(url)) = MAPPINGS.get().unwrap().get(&post.id) else { continue; }; let mut post_path = output_path .iter() .chain( url.path_segments() .ok_or_else(|| BoxError::from("unable to get path segments of {url}"))? .map(OsStr::new), ) .collect::<PathBuf>(); post_path.set_extension("html"); let post_html = Layout { title: &format!( "Post from {} on {}", actor.preferred_username, post.human_published() ), body: Show { actor: &actor, activity: post, }, actor: &actor, } .to_string(); println!("Writing {}", post_path.display()); fs::create_dir_all(&post_path.parent().expect("post has parent dir"))?; fs::write(&post_path, post_html.as_bytes())?; } Ok(()) } markup::define! { Layout<'a, Body: markup::Render>(title: &'a str, body: Body, actor: &'a pleroma::Actor) { @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 } @body @Footer { } } } } Index<'a>(actor: &'a pleroma::Actor, activities: &'a [&'a pleroma::Activity]) { main { p { @activities.len() " posts:" } @for activity in activities.iter().rev() { @Activity { actor, activity } } } } Show<'a>(actor: &'a pleroma::Actor, activity: &'a pleroma::Activity) { a[href="/"] { "☜ Back to home page" } @Activity { actor, activity } } 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.path()))] { 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) @if !activity.attachment.is_empty() { ul[class="activity-attachments"] { @for attachment in activity.attachment.iter() { li { @Attachment { attachment } } } } } } } 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"]; } } } Attachment<'a>(attachment: &'a pleroma::activity::Attachment) { @match attachment.media_type.as_str() { "image/gif" |"image/jpeg" | "image/png" => { img[src=&attachment.url, loading="lazy"]; } _ => { a[href=&attachment.url] { @attachment.media_type " attachment" } } } } } 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) } }