From 22703247115d8b262d5f77cf5f767ac62cf5de90 Mon Sep 17 00:00:00 2001 From: nullishamy Date: Sat, 26 Apr 2025 12:17:32 +0100 Subject: [PATCH 1/4] feat: basic Announce support --- ferri-server/src/endpoints/api/status.rs | 1 + ferri-server/src/endpoints/api/timeline.rs | 62 ++++++++++++++- ferri-server/src/endpoints/api/user.rs | 1 + ferri-server/src/endpoints/custom.rs | 1 + ferri-server/src/endpoints/inbox.rs | 93 +++++++++++++++++++++- ferri-server/src/endpoints/user.rs | 1 + ferri-server/src/types/activity.rs | 15 +++- ferri-server/src/types/content.rs | 3 + ferri-server/src/types/mod.rs | 1 + migrations/20250410182845_add_post.sql | 4 +- 10 files changed, 178 insertions(+), 4 deletions(-) diff --git a/ferri-server/src/endpoints/api/status.rs b/ferri-server/src/endpoints/api/status.rs index c0e6898..3cf1eb2 100644 --- a/ferri-server/src/endpoints/api/status.rs +++ b/ferri-server/src/endpoints/api/status.rs @@ -111,6 +111,7 @@ async fn create_status( favourites_count: 0, favourited: false, reblogged: false, + reblog: None, muted: false, bookmarked: false, media_attachments: vec![], diff --git a/ferri-server/src/endpoints/api/timeline.rs b/ferri-server/src/endpoints/api/timeline.rs index ae1f771..c81e387 100644 --- a/ferri-server/src/endpoints/api/timeline.rs +++ b/ferri-server/src/endpoints/api/timeline.rs @@ -27,6 +27,7 @@ pub struct TimelineStatus { pub reblogged: bool, pub muted: bool, pub bookmarked: bool, + pub reblog: Option>, pub media_attachments: Vec<()>, pub account: TimelineAccount, } @@ -40,7 +41,7 @@ pub async fn home( let posts = sqlx::query!( r#" SELECT p.id as "post_id", u.id as "user_id", p.content, p.uri as "post_uri", - u.username, u.display_name, u.actor_id, p.created_at + u.username, u.display_name, u.actor_id, p.created_at, p.boosted_post_id FROM post p INNER JOIN user u on p.user_id = u.id "# @@ -51,6 +52,64 @@ pub async fn home( let mut out = Vec::::new(); for record in posts { + let mut boost: Option> = None; + if let Some(boosted_id) = record.boosted_post_id { + let record = sqlx::query!( + r#" + SELECT p.id as "post_id", u.id as "user_id", p.content, p.uri as "post_uri", + u.username, u.display_name, u.actor_id, p.created_at, p.boosted_post_id + FROM post p + INNER JOIN user u on p.user_id = u.id + WHERE p.id = ?1 + "#, boosted_id) + .fetch_one(&mut **db) + .await + .unwrap(); + + let user_uri = format!("https://ferri.amy.mov/users/{}", record.user_id); + boost = Some(Box::new(TimelineStatus { + id: record.post_id.clone(), + created_at: record.created_at.clone(), + in_reply_to_id: None, + in_reply_to_account_id: None, + content: record.content.clone(), + visibility: "public".to_string(), + spoiler_text: "".to_string(), + sensitive: false, + uri: record.post_uri.clone(), + url: record.post_uri.clone(), + replies_count: 0, + reblogs_count: 0, + favourites_count: 0, + favourited: false, + reblogged: false, + reblog: boost, + muted: false, + bookmarked: false, + media_attachments: vec![], + account: CredentialAcount { + id: record.user_id.clone(), + username: record.username.clone(), + acct: record.username.clone(), + display_name: record.display_name.clone(), + locked: false, + bot: false, + created_at: "2025-04-10T22:12:09Z".to_string(), + attribution_domains: vec![], + note: "".to_string(), + url: user_uri, + avatar: "https://ferri.amy.mov/assets/pfp.png".to_string(), + avatar_static: "https://ferri.amy.mov/assets/pfp.png".to_string(), + header: "https://ferri.amy.mov/assets/pfp.png".to_string(), + header_static: "https://ferri.amy.mov/assets/pfp.png".to_string(), + followers_count: 1, + following_count: 1, + statuses_count: 1, + last_status_at: "2025-04-10T22:14:34Z".to_string(), + }, + })) + } + let user_uri = format!("https://ferri.amy.mov/users/{}", record.username); out.push(TimelineStatus { id: record.post_id.clone(), @@ -68,6 +127,7 @@ pub async fn home( favourites_count: 0, favourited: false, reblogged: false, + reblog: boost, muted: false, bookmarked: false, media_attachments: vec![], diff --git a/ferri-server/src/endpoints/api/user.rs b/ferri-server/src/endpoints/api/user.rs index a6be6aa..2a686d4 100644 --- a/ferri-server/src/endpoints/api/user.rs +++ b/ferri-server/src/endpoints/api/user.rs @@ -165,6 +165,7 @@ pub async fn statuses( favourites_count: 0, favourited: false, reblogged: false, + reblog: None, muted: false, bookmarked: false, media_attachments: vec![], diff --git a/ferri-server/src/endpoints/custom.rs b/ferri-server/src/endpoints/custom.rs index ff3bea8..9bf268f 100644 --- a/ferri-server/src/endpoints/custom.rs +++ b/ferri-server/src/endpoints/custom.rs @@ -99,6 +99,7 @@ pub async fn test(http: &State) -> &'static str { ts: "2025-04-10T10:48:11Z".to_string(), to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()], cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()], + attributed_to: None }, ts: "2025-04-10T10:48:11Z".to_string(), to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()], diff --git a/ferri-server/src/endpoints/inbox.rs b/ferri-server/src/endpoints/inbox.rs index 5d1972a..bb95091 100644 --- a/ferri-server/src/endpoints/inbox.rs +++ b/ferri-server/src/endpoints/inbox.rs @@ -11,7 +11,7 @@ use tracing::{event, span, Level, debug, warn, info}; use crate::{ Db, http::HttpClient, - types::{Person, activity}, + types::{Person, content::Post, activity}, }; fn handle_delete_activity(activity: activity::DeleteActivity) { @@ -142,6 +142,92 @@ async fn handle_like_activity(activity: activity::LikeActivity, mut db: Connecti } } +async fn handle_boost_activity( + activity: activity::BoostActivity, + http: &HttpClient, + mut db: Connection, +) { + let key_id = "https://ferri.amy.mov/users/amy#main-key"; + dbg!(&activity); + let post = http + .get(&activity.object) + .activity() + .sign(&key_id) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + dbg!(&post); + let attribution = post.attributed_to.unwrap(); + let post_user = http + .get(&attribution) + .activity() + .sign(&key_id) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + let user = http + .get(&activity.actor) + .activity() + .sign(&key_id) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + dbg!(&post_user); + + debug!("creating actor {}", activity.actor); + create_actor(&user, &activity.actor, &mut **db).await; + + debug!("creating user {}", activity.actor); + create_user(&user, &activity.actor, &mut **db).await; + + debug!("creating actor {}", attribution); + create_actor(&post_user, &attribution, &mut **db).await; + + debug!("creating user {}", attribution); + create_user(&post_user, &attribution, &mut **db).await; + + let attributed_user = ap::User::from_actor_id(&attribution, &mut **db).await; + let actor_user = ap::User::from_actor_id(&activity.actor, &mut **db).await; + + let base_id = ap::new_id(); + let now = ap::new_ts(); + + let reblog_id = ap::new_id(); + + let attr_id = attributed_user.id(); + sqlx::query!(" + INSERT INTO post (id, uri, user_id, content, created_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ", reblog_id, post.id, attr_id, post.content, post.ts) + .execute(&mut **db) + .await + .unwrap(); + + let uri = format!("https://ferri.amy.mov/users/{}/posts/{}", actor_user.id(), post.id); + let user_id = actor_user.id(); + + sqlx::query!(" + INSERT INTO post (id, uri, user_id, content, created_at, boosted_post_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ", base_id, uri, user_id, "", now, reblog_id) + .execute(&mut **db) + .await + .unwrap(); + +} + async fn handle_create_activity( activity: activity::CreateActivity, http: &HttpClient, @@ -218,6 +304,11 @@ pub async fn inbox(db: Connection, http: &State, user: &str, bod let activity = serde_json::from_str::(&body).unwrap(); handle_like_activity(activity, db).await; } + "Announce" => { + let activity = serde_json::from_str::(&body).unwrap(); + handle_boost_activity(activity, http.inner(), db).await; + } + act => { warn!(act, body, "unknown activity"); } diff --git a/ferri-server/src/endpoints/user.rs b/ferri-server/src/endpoints/user.rs index 3963926..a205e60 100644 --- a/ferri-server/src/endpoints/user.rs +++ b/ferri-server/src/endpoints/user.rs @@ -107,6 +107,7 @@ pub async fn post( Json(content::Post { context: "https://www.w3.org/ns/activitystreams".to_string(), id: format!("https://ferri.amy.mov/users/{}/posts/{}", uuid, post.id), + attributed_to: Some(format!("https://ferri.amy.mov/users/{}/posts/{}", uuid, post.id)), ty: "Note".to_string(), content: post.content, ts: post.created_at, diff --git a/ferri-server/src/types/activity.rs b/ferri-server/src/types/activity.rs index 76b9acc..29b0151 100644 --- a/ferri-server/src/types/activity.rs +++ b/ferri-server/src/types/activity.rs @@ -1,5 +1,4 @@ use rocket::serde::{Deserialize, Serialize}; - use crate::types::content::Post; #[derive(Serialize, Deserialize, Debug)] @@ -60,3 +59,17 @@ pub struct AcceptActivity { pub object: String, pub actor: String, } + +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub struct BoostActivity { + #[serde(rename = "type")] + pub ty: String, + + pub id: String, + pub actor: String, + pub published: String, + pub to: Vec, + pub cc: Vec, + pub object: String, +} diff --git a/ferri-server/src/types/content.rs b/ferri-server/src/types/content.rs index c6da908..e35c5d9 100644 --- a/ferri-server/src/types/content.rs +++ b/ferri-server/src/types/content.rs @@ -15,4 +15,7 @@ pub struct Post { pub content: String, pub to: Vec, pub cc: Vec, + + #[serde(rename = "attributedTo")] + pub attributed_to: Option } diff --git a/ferri-server/src/types/mod.rs b/ferri-server/src/types/mod.rs index 65ed38e..7767a8d 100644 --- a/ferri-server/src/types/mod.rs +++ b/ferri-server/src/types/mod.rs @@ -34,6 +34,7 @@ pub struct Person { pub inbox: String, pub outbox: String, pub preferred_username: String, + #[serde(default)] pub name: String, pub public_key: Option, } diff --git a/migrations/20250410182845_add_post.sql b/migrations/20250410182845_add_post.sql index 96088c8..04c66bd 100644 --- a/migrations/20250410182845_add_post.sql +++ b/migrations/20250410182845_add_post.sql @@ -5,6 +5,8 @@ CREATE TABLE IF NOT EXISTS post user_id TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL, + boosted_post_id TEXT, - FOREIGN KEY(user_id) REFERENCES user(id) + FOREIGN KEY(user_id) REFERENCES user(id), + FOREIGN KEY(boosted_post_id) REFERENCES post(id) ); From 4b88100373da71a1dd5cb22c5e371c0bab01515a Mon Sep 17 00:00:00 2001 From: nullishamy Date: Sat, 26 Apr 2025 12:44:45 +0100 Subject: [PATCH 2/4] feat: queue infra --- ferri-main/src/ap/mod.rs | 3 ++ ferri-main/src/ap/request_queue.rs | 56 ++++++++++++++++++++++++++++ ferri-server/src/endpoints/custom.rs | 6 ++- ferri-server/src/lib.rs | 15 +++++++- 4 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 ferri-main/src/ap/request_queue.rs diff --git a/ferri-main/src/ap/mod.rs b/ferri-main/src/ap/mod.rs index f86ba50..98d7330 100644 --- a/ferri-main/src/ap/mod.rs +++ b/ferri-main/src/ap/mod.rs @@ -12,6 +12,9 @@ pub use user::*; mod post; pub use post::*; +mod request_queue; +pub use request_queue::*; + pub const AS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; pub fn new_id() -> String { diff --git a/ferri-main/src/ap/request_queue.rs b/ferri-main/src/ap/request_queue.rs new file mode 100644 index 0000000..65c7f07 --- /dev/null +++ b/ferri-main/src/ap/request_queue.rs @@ -0,0 +1,56 @@ +use std::sync::mpsc; +use std::thread; +use tracing::{debug, info, span, Level}; + +#[derive(Debug)] +pub enum QueueMessage { + Heartbeat +} + +pub struct RequestQueue { + name: &'static str, + send: mpsc::Sender, + recv: mpsc::Receiver +} + +#[derive(Clone)] +pub struct QueueHandle { + send: mpsc::Sender +} + +impl QueueHandle { + pub fn send(&self, msg: QueueMessage) { + self.send.send(msg).unwrap(); + } +} + +impl RequestQueue { + pub fn new(name: &'static str) -> Self { + let (send, recv) = mpsc::channel(); + Self { + name, + send, + recv + } + } + + pub fn spawn(self) -> QueueHandle { + info!("starting up queue '{}'", self.name); + + thread::spawn(move || { + info!("queue '{}' up", self.name); + let recv = self.recv; + + while let Ok(req) = recv.recv() { + let s = span!(Level::INFO, "queue", queue_name = self.name); + let _enter = s.enter(); + + info!(?req, "got a message into the queue"); + + drop(_enter); + } + }); + + QueueHandle { send: self.send } + } +} diff --git a/ferri-server/src/endpoints/custom.rs b/ferri-server/src/endpoints/custom.rs index 9bf268f..8256121 100644 --- a/ferri-server/src/endpoints/custom.rs +++ b/ferri-server/src/endpoints/custom.rs @@ -1,6 +1,8 @@ use main::ap::http::HttpClient; use rocket::{State, get, response::status}; use rocket_db_pools::Connection; +use main::ap; +use crate::OutboundQueue; use uuid::Uuid; @@ -84,7 +86,9 @@ pub async fn resolve_user(acct: &str, host: &str) -> types::Person { } #[get("/test")] -pub async fn test(http: &State) -> &'static str { +pub async fn test(http: &State, outbound: &State) -> &'static str { + outbound.0.send(ap::QueueMessage::Heartbeat); + let user = resolve_user("amy@fedi.amy.mov", "fedi.amy.mov").await; let post = activity::CreateActivity { diff --git a/ferri-server/src/lib.rs b/ferri-server/src/lib.rs index cced509..f66921b 100644 --- a/ferri-server/src/lib.rs +++ b/ferri-server/src/lib.rs @@ -6,6 +6,8 @@ use endpoints::{ use tracing::Level; use tracing_subscriber::fmt; +use main::ap; + use main::ap::http; use main::config::Config; use rocket::{ @@ -88,7 +90,8 @@ impl<'a> FromRequest<'a> for AuthenticatedUser { } } - +pub struct OutboundQueue(pub ap::QueueHandle); +pub struct InboundQueue(pub ap::QueueHandle); pub fn launch(cfg: Config) -> Rocket { let format = fmt::format() @@ -104,11 +107,19 @@ pub fn launch(cfg: Config) -> Rocket { .event_format(format) .with_writer(std::io::stdout) .init(); - + + let outbound = ap::RequestQueue::new("outbound"); + let outbound_handle = outbound.spawn(); + + let inbound = ap::RequestQueue::new("inbound"); + let inbound_handle = inbound.spawn(); + let http_client = http::HttpClient::new(); build() .manage(cfg) .manage(http_client) + .manage(OutboundQueue(outbound_handle)) + .manage(InboundQueue(inbound_handle)) .attach(Db::init()) .attach(cors::CORS) .mount("/assets", rocket::fs::FileServer::from("./assets")) From 9bc6c12392e4bb61e1fa6dbe14d110ffd1cbc2f7 Mon Sep 17 00:00:00 2001 From: nullishamy Date: Sat, 26 Apr 2025 13:08:49 +0100 Subject: [PATCH 3/4] fix: cleanup warnings; centralise url creation into config --- ferri-main/src/ap/request_queue.rs | 2 +- ferri-main/src/config/mod.rs | 38 ++++++++++++++++++++++ ferri-server/src/endpoints/api/instance.rs | 8 +++-- ferri-server/src/endpoints/api/status.rs | 8 ++--- ferri-server/src/endpoints/api/timeline.rs | 14 ++++---- ferri-server/src/endpoints/api/user.rs | 8 ++--- ferri-server/src/endpoints/custom.rs | 2 ++ ferri-server/src/endpoints/oauth.rs | 14 ++++---- ferri-server/src/endpoints/user.rs | 36 +++++++++++--------- ferri-server/src/endpoints/well_known.rs | 13 ++++---- ferri-server/src/lib.rs | 14 +++----- 11 files changed, 102 insertions(+), 55 deletions(-) diff --git a/ferri-main/src/ap/request_queue.rs b/ferri-main/src/ap/request_queue.rs index 65c7f07..9088266 100644 --- a/ferri-main/src/ap/request_queue.rs +++ b/ferri-main/src/ap/request_queue.rs @@ -1,6 +1,6 @@ use std::sync::mpsc; use std::thread; -use tracing::{debug, info, span, Level}; +use tracing::{info, span, Level}; #[derive(Debug)] pub enum QueueMessage { diff --git a/ferri-main/src/config/mod.rs b/ferri-main/src/config/mod.rs index 5af1eef..79305d2 100644 --- a/ferri-main/src/config/mod.rs +++ b/ferri-main/src/config/mod.rs @@ -9,3 +9,41 @@ pub struct ServerConfig { pub struct Config { pub server: ServerConfig, } + +impl Config { + pub fn host(&self) -> &str { + &self.server.host + } + + pub fn user_url(&self, user_uuid: &str) -> String { + format!("{}/users/{}", self.host(), user_uuid) + } + + pub fn user_web_url(&self, user_name: &str) -> String { + format!("{}/{}", self.host(), user_name) + } + + pub fn followers_url(&self, user_uuid: &str) -> String { + format!("{}/followers", self.user_url(user_uuid)) + } + + pub fn following_url(&self, user_uuid: &str) -> String { + format!("{}/following", self.user_url(user_uuid)) + } + + pub fn inbox_url(&self, user_uuid: &str) -> String { + format!("{}/inbox", self.user_url(user_uuid)) + } + + pub fn outbox_url(&self, user_uuid: &str) -> String { + format!("{}/outbox", self.user_url(user_uuid)) + } + + pub fn post_url(&self, poster_uuid: &str, post_uuid: &str) -> String { + format!("{}/{}", self.user_url(poster_uuid), post_uuid) + } + + pub fn activity_url(&self, activity_uuid: &str) -> String { + format!("{}/activities/{}", self.host(), activity_uuid) + } +} diff --git a/ferri-server/src/endpoints/api/instance.rs b/ferri-server/src/endpoints/api/instance.rs index 0a76ba6..9fab90f 100644 --- a/ferri-server/src/endpoints/api/instance.rs +++ b/ferri-server/src/endpoints/api/instance.rs @@ -1,4 +1,6 @@ -use rocket::{get, serde::json::Json}; +use rocket::{get, serde::json::Json, State}; + +use crate::Config; use crate::types::instance::{ Accounts, Configuration, Contact, Instance, MediaAttachments, Polls, Registrations, Statuses, @@ -6,9 +8,9 @@ use crate::types::instance::{ }; #[get("/instance")] -pub async fn instance() -> Json { +pub async fn instance(config: &State) -> Json { Json(Instance { - domain: "ferri.amy.mov".to_string(), + domain: config.host().to_string(), title: "Ferri".to_string(), version: "0.0.1".to_string(), source_url: "https://forge.amy.mov/amy/ferri".to_string(), diff --git a/ferri-server/src/endpoints/api/status.rs b/ferri-server/src/endpoints/api/status.rs index 3cf1eb2..e716de4 100644 --- a/ferri-server/src/endpoints/api/status.rs +++ b/ferri-server/src/endpoints/api/status.rs @@ -26,11 +26,11 @@ pub struct StatusContext { descendants: Vec } -#[get("/statuses//context")] +#[get("/statuses/<_status>/context")] pub async fn status_context( - status: &str, - user: AuthenticatedUser, - mut db: Connection + _status: &str, + _user: AuthenticatedUser, + _db: Connection ) -> Json { Json(StatusContext { ancestors: vec![], diff --git a/ferri-server/src/endpoints/api/timeline.rs b/ferri-server/src/endpoints/api/timeline.rs index c81e387..884b82e 100644 --- a/ferri-server/src/endpoints/api/timeline.rs +++ b/ferri-server/src/endpoints/api/timeline.rs @@ -1,5 +1,6 @@ -use crate::{AuthenticatedUser, Db, endpoints::api::user::CredentialAcount}; +use crate::{AuthenticatedUser, Db, endpoints::api::user::CredentialAcount, Config}; use rocket::{ + State, get, serde::{Deserialize, Serialize, json::Json}, }; @@ -32,11 +33,12 @@ pub struct TimelineStatus { pub account: TimelineAccount, } -#[get("/timelines/home?")] +#[get("/timelines/home?<_limit>")] pub async fn home( mut db: Connection, - limit: i64, - user: AuthenticatedUser, + config: &State, + _limit: i64, + _user: AuthenticatedUser, ) -> Json> { let posts = sqlx::query!( r#" @@ -66,7 +68,7 @@ pub async fn home( .await .unwrap(); - let user_uri = format!("https://ferri.amy.mov/users/{}", record.user_id); + let user_uri = config.user_url(&record.user_id); boost = Some(Box::new(TimelineStatus { id: record.post_id.clone(), created_at: record.created_at.clone(), @@ -110,7 +112,7 @@ pub async fn home( })) } - let user_uri = format!("https://ferri.amy.mov/users/{}", record.username); + let user_uri = config.user_web_url(&record.username); out.push(TimelineStatus { id: record.post_id.clone(), created_at: record.created_at.clone(), diff --git a/ferri-server/src/endpoints/api/user.rs b/ferri-server/src/endpoints/api/user.rs index 2a686d4..38428e2 100644 --- a/ferri-server/src/endpoints/api/user.rs +++ b/ferri-server/src/endpoints/api/user.rs @@ -95,7 +95,7 @@ pub async fn new_follow( pub async fn account( mut db: Connection, uuid: &str, - user: AuthenticatedUser, + _user: AuthenticatedUser, ) -> Result, NotFound> { let user = ap::User::from_id(uuid, &mut **db) .await @@ -123,12 +123,12 @@ pub async fn account( })) } -#[get("/accounts//statuses?")] +#[get("/accounts//statuses?<_limit>")] pub async fn statuses( mut db: Connection, uuid: &str, - limit: Option, - user: AuthenticatedUser, + _limit: Option, + _user: AuthenticatedUser, ) -> Result>, NotFound> { let user = ap::User::from_id(uuid, &mut **db) .await diff --git a/ferri-server/src/endpoints/custom.rs b/ferri-server/src/endpoints/custom.rs index 8256121..8d7c57f 100644 --- a/ferri-server/src/endpoints/custom.rs +++ b/ferri-server/src/endpoints/custom.rs @@ -123,5 +123,7 @@ pub async fn test(http: &State, outbound: &State) -> .await .unwrap(); + dbg!(follow); + "Hello, world!" } diff --git a/ferri-server/src/endpoints/oauth.rs b/ferri-server/src/endpoints/oauth.rs index 81c8d55..ae897fa 100644 --- a/ferri-server/src/endpoints/oauth.rs +++ b/ferri-server/src/endpoints/oauth.rs @@ -8,12 +8,12 @@ use rocket::{ }; use rocket_db_pools::Connection; -#[get("/oauth/authorize?&&&")] +#[get("/oauth/authorize?&&&<_response_type>")] pub async fn authorize( client_id: &str, scope: &str, redirect_uri: &str, - response_type: &str, + _response_type: &str, mut db: Connection, ) -> Redirect { // For now, we will always authorize the request and assign it to an admin user @@ -68,11 +68,11 @@ pub struct Token { #[derive(Deserialize, Debug, FromForm)] #[serde(crate = "rocket::serde")] pub struct NewTokenRequest { - client_id: String, - redirect_uri: String, - grant_type: String, - code: String, - client_secret: String, + // pub client_id: String, + // pub redirect_uri: String, + // pub grant_type: String, + pub code: String, + // pub client_secret: String, } #[post("/oauth/token", data = "")] diff --git a/ferri-server/src/endpoints/user.rs b/ferri-server/src/endpoints/user.rs index a205e60..0464123 100644 --- a/ferri-server/src/endpoints/user.rs +++ b/ferri-server/src/endpoints/user.rs @@ -1,17 +1,18 @@ use main::ap; -use rocket::{get, http::ContentType, serde::json::Json}; +use rocket::{get, http::ContentType, serde::json::Json, State}; use rocket_db_pools::Connection; use rocket::response::status::NotFound; use crate::{ + Config, Db, types::{OrderedCollection, Person, UserKey, content}, }; use super::activity_type; -#[get("/users//inbox")] -pub async fn inbox(user: String) -> Json { +#[get("/users/<_user>/inbox")] +pub async fn inbox(_user: String) -> Json { Json(OrderedCollection { ty: "OrderedCollection".to_string(), total_items: 0, @@ -19,8 +20,8 @@ pub async fn inbox(user: String) -> Json { }) } -#[get("/users//outbox")] -pub async fn outbox(user: String) -> Json { +#[get("/users/<_user>/outbox")] +pub async fn outbox(_user: String) -> Json { Json(OrderedCollection { ty: "OrderedCollection".to_string(), total_items: 0, @@ -89,6 +90,7 @@ pub async fn following(mut db: Connection, uuid: &str) -> Result/posts/")] pub async fn post( mut db: Connection, + config: &State, uuid: &str, post: String, ) -> (ContentType, Json) { @@ -106,19 +108,23 @@ pub async fn post( activity_type(), Json(content::Post { context: "https://www.w3.org/ns/activitystreams".to_string(), - id: format!("https://ferri.amy.mov/users/{}/posts/{}", uuid, post.id), - attributed_to: Some(format!("https://ferri.amy.mov/users/{}/posts/{}", uuid, post.id)), + id: config.post_url(uuid, &post.id), + attributed_to: Some(config.user_url(uuid)), ty: "Note".to_string(), content: post.content, ts: post.created_at, - to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()], + to: vec![config.followers_url(uuid)], cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()], }), ) } #[get("/users/")] -pub async fn user(mut db: Connection, uuid: &str) -> Result<(ContentType, Json), NotFound> { +pub async fn user( + mut db: Connection, + config: &State, + uuid: &str +) -> Result<(ContentType, Json), NotFound> { let user = ap::User::from_id(uuid, &mut **db) .await .map_err(|e| NotFound(e.to_string()))?; @@ -128,17 +134,17 @@ pub async fn user(mut db: Connection, uuid: &str) -> Result<(ContentType, Js Json(Person { context: "https://www.w3.org/ns/activitystreams".to_string(), ty: "Person".to_string(), - id: format!("https://ferri.amy.mov/users/{}", user.id()), + id: config.user_url(user.id()), name: user.username().to_string(), preferred_username: user.display_name().to_string(), - followers: format!("https://ferri.amy.mov/users/{}/followers", uuid), - following: format!("https://ferri.amy.mov/users/{}/following", uuid), + followers: config.followers_url(user.id()), + following: config.following_url(user.id()), summary: format!("ferri {}", user.username()), - inbox: format!("https://ferri.amy.mov/users/{}/inbox", uuid), - outbox: format!("https://ferri.amy.mov/users/{}/outbox", uuid), + inbox: config.inbox_url(user.id()), + outbox: config.outbox_url(user.id()), public_key: Some(UserKey { id: format!("https://ferri.amy.mov/users/{}#main-key", uuid), - owner: format!("https://ferri.amy.mov/users/{}", uuid), + owner: config.user_url(user.id()), public_key: include_str!("../../../public.pem").to_string(), }), }), diff --git a/ferri-server/src/endpoints/well_known.rs b/ferri-server/src/endpoints/well_known.rs index 9692b61..fe03322 100644 --- a/ferri-server/src/endpoints/well_known.rs +++ b/ferri-server/src/endpoints/well_known.rs @@ -1,9 +1,10 @@ use main::ap; -use rocket::{get, serde::json::Json}; +use rocket::{get, serde::json::Json, State}; use rocket_db_pools::Connection; use tracing::info; use crate::{ + Config, Db, types::webfinger::{Link, WebfingerResponse}, }; @@ -20,7 +21,7 @@ pub async fn host_meta() -> &'static str { // https://mastodon.social/.well-known/webfinger?resource=acct:gargron@mastodon.social #[get("/.well-known/webfinger?")] -pub async fn webfinger(mut db: Connection, resource: &str) -> Json { +pub async fn webfinger(mut db: Connection, config: &State, resource: &str) -> Json { info!(?resource, "incoming webfinger request"); let acct = resource.strip_prefix("acct:").unwrap(); @@ -30,19 +31,19 @@ pub async fn webfinger(mut db: Connection, resource: &str) -> Json) -> (ContentType, &'static str) { +async fn user_profile() -> (ContentType, &'static str) { (ContentType::HTML, "

hello

") } -#[get("/activities/")] -async fn activity_endpoint(activity: String) { +#[get("/activities/<_activity>")] +async fn activity_endpoint(_activity: String) { } #[derive(Debug)] -struct AuthenticatedUser { +pub struct AuthenticatedUser { pub username: String, pub id: String, pub token: String, @@ -45,10 +44,7 @@ struct AuthenticatedUser { } #[derive(Debug)] -enum LoginError { - InvalidData, - UsernameDoesNotExist, - WrongPassword, +pub enum LoginError { } #[rocket::async_trait] From d59660da3738b78a2d990a0b2f2eac7f41aadacb Mon Sep 17 00:00:00 2001 From: nullishamy Date: Mon, 28 Apr 2025 00:26:59 +0100 Subject: [PATCH 4/4] feat: http wrapper, start type rewrite, add nextest --- Cargo.lock | 3 + Cargo.toml | 1 + ferri-main/Cargo.toml | 2 +- ferri-main/src/ap/post.rs | 2 +- ferri-main/src/ap/request_queue.rs | 3 + ferri-main/src/ap/user.rs | 7 +- ferri-main/src/lib.rs | 2 + ferri-main/src/types_rewrite/convert.rs | 28 ++++ ferri-main/src/types_rewrite/mod.rs | 87 +++++++++++ ferri-server/Cargo.toml | 5 +- ferri-server/src/endpoints/api/status.rs | 55 +++---- ferri-server/src/endpoints/api/timeline.rs | 3 +- ferri-server/src/endpoints/inbox.rs | 174 +++++++++++---------- ferri-server/src/endpoints/user.rs | 67 +++++--- ferri-server/src/http_wrapper.rs | 71 +++++++++ ferri-server/src/lib.rs | 1 + flake.nix | 1 + migrations/20250410112325_add_follow.sql | 1 + 18 files changed, 369 insertions(+), 144 deletions(-) create mode 100644 ferri-main/src/types_rewrite/convert.rs create mode 100644 ferri-main/src/types_rewrite/mod.rs create mode 100644 ferri-server/src/http_wrapper.rs diff --git a/Cargo.lock b/Cargo.lock index fc47954..fe3d61d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2162,7 +2162,10 @@ dependencies = [ "reqwest", "rocket", "rocket_db_pools", + "serde", + "serde_json", "sqlx", + "thiserror 2.0.12", "tracing", "tracing-subscriber", "url", diff --git a/Cargo.toml b/Cargo.toml index 0f7cb23..94415ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ uuid = { version = "1.16.0", features = ["v4"] } chrono = "0.4.40" rand = "0.8" thiserror = "2.0.12" +serde_json = "1.0.140" tracing = "0.1.40" tracing-appender = "0.2.3" diff --git a/ferri-main/Cargo.toml b/ferri-main/Cargo.toml index cca1ac5..77dc8d9 100644 --- a/ferri-main/Cargo.toml +++ b/ferri-main/Cargo.toml @@ -12,8 +12,8 @@ uuid = { workspace = true } rand = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +serde_json = { workspace = true } base64 = "0.22.1" rsa = { version = "0.9.8", features = ["sha2"] } url = "2.5.4" -serde_json = "1.0.140" \ No newline at end of file diff --git a/ferri-main/src/ap/post.rs b/ferri-main/src/ap/post.rs index 53c33e0..a42a246 100644 --- a/ferri-main/src/ap/post.rs +++ b/ferri-main/src/ap/post.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::Sqlite; -const POST_TYPE: &str = "Post"; +const POST_TYPE: &str = "Note"; #[derive(Clone)] pub struct Post { diff --git a/ferri-main/src/ap/request_queue.rs b/ferri-main/src/ap/request_queue.rs index 9088266..2a230df 100644 --- a/ferri-main/src/ap/request_queue.rs +++ b/ferri-main/src/ap/request_queue.rs @@ -42,6 +42,9 @@ impl RequestQueue { let recv = self.recv; while let Ok(req) = recv.recv() { + // FIXME: When we make this do async things we will need to add tokio and + // use proper async handled spans as the enter/drop won't work. + // See inbox.rs for how we handle that. let s = span!(Level::INFO, "queue", queue_name = self.name); let _enter = s.enter(); diff --git a/ferri-main/src/ap/user.rs b/ferri-main/src/ap/user.rs index b080181..361d0d8 100644 --- a/ferri-main/src/ap/user.rs +++ b/ferri-main/src/ap/user.rs @@ -106,9 +106,10 @@ impl User { "#, username ) - .fetch_one(conn) - .await - .unwrap(); + .fetch_one(conn) + .await + .unwrap(); + User { id: user.id, username: user.username, diff --git a/ferri-main/src/lib.rs b/ferri-main/src/lib.rs index 620300a..31cafd7 100644 --- a/ferri-main/src/lib.rs +++ b/ferri-main/src/lib.rs @@ -1,5 +1,7 @@ pub mod ap; pub mod config; +mod types_rewrite; + use rand::{Rng, distributions::Alphanumeric}; pub fn gen_token(len: usize) -> String { diff --git a/ferri-main/src/types_rewrite/convert.rs b/ferri-main/src/types_rewrite/convert.rs new file mode 100644 index 0000000..16ed63c --- /dev/null +++ b/ferri-main/src/types_rewrite/convert.rs @@ -0,0 +1,28 @@ +use crate::types_rewrite::api; +use crate::types_rewrite::ap; +use crate::types_rewrite::db; + +use crate::types_rewrite::{Object, as_context}; + +impl From for ap::Actor { + fn from(val: db::Actor) -> ap::Actor { + ap::Actor { + obj: Object { + context: as_context(), + id: val.id + }, + inbox: val.inbox, + outbox: val.outbox + } + } +} + +impl From for db::Actor { + fn from(val: ap::Actor) -> db::Actor { + db::Actor { + id: val.obj.id, + inbox: val.inbox, + outbox: val.outbox + } + } +} diff --git a/ferri-main/src/types_rewrite/mod.rs b/ferri-main/src/types_rewrite/mod.rs new file mode 100644 index 0000000..b1baa5a --- /dev/null +++ b/ferri-main/src/types_rewrite/mod.rs @@ -0,0 +1,87 @@ +use serde::{Serialize, Deserialize}; + +mod convert; +pub use convert::*; + +pub const AS_CONTEXT_RAW: &'static str = "https://www.w3.org/ns/activitystreams"; +pub fn as_context() -> ObjectContext { + ObjectContext::Str(AS_CONTEXT_RAW.to_string()) +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +#[serde(untagged)] +pub enum ObjectContext { + Str(String), + Vec(Vec), +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct ObjectUri(String); + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct Object { + #[serde(rename = "@context")] + context: ObjectContext, + id: ObjectUri, +} + +pub mod db { + use serde::{Serialize, Deserialize}; + use super::*; + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub struct Actor { + pub id: ObjectUri, + pub inbox: String, + pub outbox: String, + } +} + +pub mod ap { + use serde::{Serialize, Deserialize}; + use super::*; + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub struct Actor { + #[serde(flatten)] + pub obj: Object, + + pub inbox: String, + pub outbox: String, + } +} + +pub mod api { + use serde::{Serialize, Deserialize}; + use super::*; + + // API will not really use actors so treat them as DB actors + // until we require specificity + pub type Actor = db::Actor; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ap_actor_to_db() { + let domain = "https://example.com"; + let ap = ap::Actor { + obj: Object { + context: as_context(), + id: ObjectUri(format!("{}/users/sample", domain)), + }, + inbox: format!("{}/users/sample/inbox", domain), + outbox: format!("{}/users/sample/outbox", domain), + }; + + let db: db::Actor = ap.into(); + + assert_eq!(db, db::Actor { + id: ObjectUri("https://example.com/users/sample".to_string()), + inbox: "https://example.com/users/sample/inbox".to_string(), + outbox: "https://example.com/users/sample/outbox".to_string(), + }); + } +} diff --git a/ferri-server/Cargo.toml b/ferri-server/Cargo.toml index 91b72fe..a378685 100644 --- a/ferri-server/Cargo.toml +++ b/ferri-server/Cargo.toml @@ -15,4 +15,7 @@ uuid = { workspace = true } chrono = { workspace = true } rand = { workspace = true } tracing = { workspace = true } -tracing-subscriber = { workspace = true } \ No newline at end of file +tracing-subscriber = { workspace = true } +thiserror = { workspace = true } +serde_json = { workspace = true } +serde = { workspace = true } \ No newline at end of file diff --git a/ferri-server/src/endpoints/api/status.rs b/ferri-server/src/endpoints/api/status.rs index e716de4..ca74a07 100644 --- a/ferri-server/src/endpoints/api/status.rs +++ b/ferri-server/src/endpoints/api/status.rs @@ -56,44 +56,37 @@ async fn create_status( post.save(&mut **db).await; - let actors = sqlx::query!("SELECT * FROM actor") - .fetch_all(&mut **db) + let actor = sqlx::query!("SELECT * FROM actor WHERE id = ?1", "https://fedi.amy.mov/users/9zkygethkdw60001") + .fetch_one(&mut **db) .await .unwrap(); - for record in actors { - // Don't send to ourselves - if record.id == user.actor_id() { - continue; - } + let create_id = format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()); - let create_id = format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()); + let activity = ap::Activity { + id: create_id, + ty: ap::ActivityType::Create, + object: post.clone().to_ap(), + to: vec![format!("{}/followers", user.uri())], + published: now, + cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()], + ..Default::default() + }; - let activity = ap::Activity { - id: create_id, - ty: ap::ActivityType::Create, - object: post.clone().to_ap(), - to: vec![format!("{}/followers", user.uri())], - published: now, - cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()], - ..Default::default() - }; + let actor = ap::Actor::from_raw( + actor.id.clone(), + actor.inbox.clone(), + actor.outbox.clone(), + ); - let actor = ap::Actor::from_raw( - record.id.clone(), - record.inbox.clone(), - record.outbox.clone(), - ); + let req = ap::OutgoingActivity { + req: activity, + signed_by: format!("{}#main-key", user.uri()), + to: actor, + }; - let req = ap::OutgoingActivity { - req: activity, - signed_by: format!("{}#main-key", user.uri()), - to: actor, - }; - - req.save(&mut **db).await; - outbox.post(req).await; - } + req.save(&mut **db).await; + outbox.post(req).await; TimelineStatus { id: post.id().to_string(), diff --git a/ferri-server/src/endpoints/api/timeline.rs b/ferri-server/src/endpoints/api/timeline.rs index 884b82e..04920e1 100644 --- a/ferri-server/src/endpoints/api/timeline.rs +++ b/ferri-server/src/endpoints/api/timeline.rs @@ -33,11 +33,10 @@ pub struct TimelineStatus { pub account: TimelineAccount, } -#[get("/timelines/home?<_limit>")] +#[get("/timelines/home")] pub async fn home( mut db: Connection, config: &State, - _limit: i64, _user: AuthenticatedUser, ) -> Json> { let posts = sqlx::query!( diff --git a/ferri-server/src/endpoints/inbox.rs b/ferri-server/src/endpoints/inbox.rs index bb95091..924ae61 100644 --- a/ferri-server/src/endpoints/inbox.rs +++ b/ferri-server/src/endpoints/inbox.rs @@ -1,4 +1,5 @@ use chrono::Local; +use tracing::Instrument; use main::ap; use rocket::serde::json::serde_json; use rocket::{State, post}; @@ -6,7 +7,8 @@ use rocket_db_pools::Connection; use sqlx::Sqlite; use url::Url; use uuid::Uuid; -use tracing::{event, span, Level, debug, warn, info}; +use tracing::{event, span, Level, debug, warn, info, error}; +use crate::http_wrapper::HttpWrapper; use crate::{ Db, @@ -46,7 +48,9 @@ async fn create_user( // HACK: Allow us to formulate a `user@host` username by assuming the actor is on the same host as the user let url = Url::parse(&actor).unwrap(); let host = url.host_str().unwrap(); - let username = format!("{}@{}", user.name, host); + info!("creating user '{}'@'{}' ({:#?})", user.preferred_username, host, user); + + let username = format!("{}@{}", user.preferred_username, host); let uuid = Uuid::new_v4().to_string(); sqlx::query!( @@ -58,7 +62,7 @@ async fn create_user( uuid, username, actor, - user.preferred_username + user.name ) .execute(conn) .await @@ -84,29 +88,27 @@ async fn create_follow( .unwrap(); } -async fn handle_follow_activity( +async fn handle_follow_activity<'a>( followed_account: &str, activity: activity::FollowActivity, - http: &HttpClient, + http: HttpWrapper<'a>, mut db: Connection, ) { - let user = http - .get(&activity.actor) - .activity() - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); + let user = http.get_person(&activity.actor).await; + if let Err(e) = user { + error!("could not load user {}: {}", activity.actor, e.to_string()); + return + } + + let user = user.unwrap(); create_actor(&user, &activity.actor, &mut **db).await; create_user(&user, &activity.actor, &mut **db).await; create_follow(&activity, &mut **db).await; let follower = ap::User::from_actor_id(&activity.actor, &mut **db).await; - let followed = ap::User::from_username(&followed_account, &mut **db).await; - let outbox = ap::Outbox::for_user(followed.clone(), http); + let followed = ap::User::from_id(&followed_account, &mut **db).await.unwrap(); + let outbox = ap::Outbox::for_user(followed.clone(), http.client()); let activity = ap::Activity { id: format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()), @@ -142,49 +144,51 @@ async fn handle_like_activity(activity: activity::LikeActivity, mut db: Connecti } } -async fn handle_boost_activity( +async fn handle_boost_activity<'a>( activity: activity::BoostActivity, - http: &HttpClient, + http: HttpWrapper<'a>, mut db: Connection, ) { let key_id = "https://ferri.amy.mov/users/amy#main-key"; dbg!(&activity); let post = http + .client() .get(&activity.object) .activity() .sign(&key_id) .send() .await .unwrap() - .json::() + .text() .await .unwrap(); + + info!("{}", post); + + let post = serde_json::from_str::(&post); + if let Err(e) = post { + error!(?e, "when decoding post"); + return + } + + let post = post.unwrap(); - dbg!(&post); + info!("{:#?}", post); let attribution = post.attributed_to.unwrap(); - let post_user = http - .get(&attribution) - .activity() - .sign(&key_id) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); + + let post_user = http.get_person(&attribution).await; + if let Err(e) = post_user { + error!("could not load post_user {}: {}", attribution, e.to_string()); + return + } + let post_user = post_user.unwrap(); - let user = http - .get(&activity.actor) - .activity() - .sign(&key_id) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - dbg!(&post_user); + let user = http.get_person(&activity.actor).await; + if let Err(e) = user { + error!("could not load actor {}: {}", activity.actor, e.to_string()); + return + } + let user = user.unwrap(); debug!("creating actor {}", activity.actor); create_actor(&user, &activity.actor, &mut **db).await; @@ -228,23 +232,21 @@ async fn handle_boost_activity( } -async fn handle_create_activity( +async fn handle_create_activity<'a>( activity: activity::CreateActivity, - http: &HttpClient, + http: HttpWrapper<'a>, mut db: Connection, ) { assert!(&activity.object.ty == "Note"); debug!("resolving user {}", activity.actor); - let user = http - .get(&activity.actor) - .activity() - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); + let user = http.get_person(&activity.actor).await; + if let Err(e) = user { + error!("could not load user {}: {}", activity.actor, e.to_string()); + return + } + + let user = user.unwrap(); debug!("creating actor {}", activity.actor); create_actor(&user, &activity.actor, &mut **db).await; @@ -284,36 +286,42 @@ pub async fn inbox(db: Connection, http: &State, user: &str, bod let min = serde_json::from_str::(&body).unwrap(); let inbox_span = span!(Level::INFO, "inbox-post", user_id = user); - let _enter = inbox_span.enter(); - event!(Level::INFO, ?min, "received an activity"); - - match min.ty.as_str() { - "Delete" => { - let activity = serde_json::from_str::(&body).unwrap(); - handle_delete_activity(activity); + async move { + event!(Level::INFO, ?min, "received an activity"); + + let key_id = "https://ferri.amy.mov/users/amy#main-key"; + let wrapper = HttpWrapper::new(http.inner(), key_id); + + match min.ty.as_str() { + "Delete" => { + let activity = serde_json::from_str::(&body).unwrap(); + handle_delete_activity(activity); + } + "Follow" => { + let activity = serde_json::from_str::(&body).unwrap(); + handle_follow_activity(user, activity, wrapper, db).await; + } + "Create" => { + let activity = serde_json::from_str::(&body).unwrap(); + handle_create_activity(activity, wrapper, db).await; + } + "Like" => { + let activity = serde_json::from_str::(&body).unwrap(); + handle_like_activity(activity, db).await; + } + "Announce" => { + let activity = serde_json::from_str::(&body).unwrap(); + handle_boost_activity(activity, wrapper, db).await; + } + + act => { + warn!(act, body, "unknown activity"); + } } - "Follow" => { - let activity = serde_json::from_str::(&body).unwrap(); - handle_follow_activity(user, activity, http.inner(), db).await; - } - "Create" => { - let activity = serde_json::from_str::(&body).unwrap(); - handle_create_activity(activity, http.inner(), db).await; - } - "Like" => { - let activity = serde_json::from_str::(&body).unwrap(); - handle_like_activity(activity, db).await; - } - "Announce" => { - let activity = serde_json::from_str::(&body).unwrap(); - handle_boost_activity(activity, http.inner(), db).await; - } - - act => { - warn!(act, body, "unknown activity"); - } - } - debug!("body in inbox: {}", body); - drop(_enter) + debug!("body in inbox: {}", body); + } + // Allow the span to be used inside the async code + // https://docs.rs/tracing/latest/tracing/span/struct.EnteredSpan.html#deref-methods-Span + .instrument(inbox_span).await; } diff --git a/ferri-server/src/endpoints/user.rs b/ferri-server/src/endpoints/user.rs index 0464123..ce24ab5 100644 --- a/ferri-server/src/endpoints/user.rs +++ b/ferri-server/src/endpoints/user.rs @@ -1,6 +1,7 @@ use main::ap; -use rocket::{get, http::ContentType, serde::json::Json, State}; +use rocket::{get, http::ContentType, serde::json::Json, State, Responder}; use rocket_db_pools::Connection; +use rocket::response::Redirect; use rocket::response::status::NotFound; use crate::{ @@ -119,34 +120,56 @@ pub async fn post( ) } +#[derive(Debug, Responder)] +pub enum UserFetchError { + NotFound(NotFound), + Moved(Redirect), +} + +type ActivityResponse = (ContentType, T); +fn ap_response(t: T) -> ActivityResponse { + (activity_type(), t) +} + +fn ap_ok(t: T) -> Result, E> { + Ok(ap_response(t)) +} + #[get("/users/")] pub async fn user( mut db: Connection, config: &State, uuid: &str -) -> Result<(ContentType, Json), NotFound> { +) -> Result>, UserFetchError> { + if uuid == "amy" { + return Err( + UserFetchError::Moved( + Redirect::permanent("https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9") + ) + ) + } + let user = ap::User::from_id(uuid, &mut **db) .await - .map_err(|e| NotFound(e.to_string()))?; + .map_err(|e| UserFetchError::NotFound(NotFound(e.to_string())))?; - Ok(( - activity_type(), - Json(Person { - context: "https://www.w3.org/ns/activitystreams".to_string(), - ty: "Person".to_string(), - id: config.user_url(user.id()), - name: user.username().to_string(), - preferred_username: user.display_name().to_string(), - followers: config.followers_url(user.id()), - following: config.following_url(user.id()), - summary: format!("ferri {}", user.username()), - inbox: config.inbox_url(user.id()), - outbox: config.outbox_url(user.id()), - public_key: Some(UserKey { - id: format!("https://ferri.amy.mov/users/{}#main-key", uuid), - owner: config.user_url(user.id()), - public_key: include_str!("../../../public.pem").to_string(), - }), + let person = Person { + context: "https://www.w3.org/ns/activitystreams".to_string(), + ty: "Person".to_string(), + id: config.user_url(user.id()), + name: user.username().to_string(), + preferred_username: user.display_name().to_string(), + followers: config.followers_url(user.id()), + following: config.following_url(user.id()), + summary: format!("ferri {}", user.username()), + inbox: config.inbox_url(user.id()), + outbox: config.outbox_url(user.id()), + public_key: Some(UserKey { + id: format!("https://ferri.amy.mov/users/{}#main-key", uuid), + owner: config.user_url(user.id()), + public_key: include_str!("../../../public.pem").to_string(), }), - )) + }; + + ap_ok(Json(person)) } diff --git a/ferri-server/src/http_wrapper.rs b/ferri-server/src/http_wrapper.rs new file mode 100644 index 0000000..0470b3f --- /dev/null +++ b/ferri-server/src/http_wrapper.rs @@ -0,0 +1,71 @@ +use thiserror::Error; +use tracing::{error, event, Level}; +use crate::http::HttpClient; +use crate::types::Person; +use std::fmt::Debug; + +pub struct HttpWrapper<'a> { + client: &'a HttpClient, + key_id: &'a str +} + +#[derive(Error, Debug)] +pub enum HttpError { + #[error("entity of type `{0}` @ URL `{1}` could not be loaded")] + LoadFailure(String, String), + #[error("entity of type `{0}` @ URL `{1}` could not be parsed ({2})")] + ParseFailure(String, String, String), +} + +impl <'a> HttpWrapper<'a> { + pub fn new(client: &'a HttpClient, key_id: &'a str) -> HttpWrapper<'a> { + Self { + client, + key_id + } + } + + pub fn client(&self) -> &'a HttpClient { + &self.client + } + + async fn get(&self, ty: &str, url: &str) -> Result { + let ty = ty.to_string(); + event!(Level::INFO, url, "loading {}", ty); + + let http_result = self.client + .get(url) + .sign(self.key_id) + .activity() + .send() + .await; + + if let Err(e) = http_result { + error!("could not load url {}: {:#?}", url, e); + return Err(HttpError::LoadFailure(ty, url.to_string())); + } + + let raw_body = http_result.unwrap().text().await; + if let Err(e) = raw_body { + error!("could not get text for url {}: {:#?}", url, e); + return Err(HttpError::LoadFailure(ty, url.to_string())); + } + + let decoded = serde_json::from_str::(&raw_body.unwrap()); + + if let Err(e) = decoded { + error!("could not parse {} for url {}: {:#?}", ty, url, e); + return Err(HttpError::ParseFailure( + ty, + url.to_string(), + e.to_string() + )); + } + + Ok(decoded.unwrap()) + } + + pub async fn get_person(&self, url: &str) -> Result { + self.get("Person", url).await + } +} diff --git a/ferri-server/src/lib.rs b/ferri-server/src/lib.rs index 495e902..520e213 100644 --- a/ferri-server/src/lib.rs +++ b/ferri-server/src/lib.rs @@ -20,6 +20,7 @@ use rocket_db_pools::{Connection, Database, sqlx}; mod cors; mod endpoints; mod types; +mod http_wrapper; #[derive(Database)] #[database("sqlite_ferri")] diff --git a/flake.nix b/flake.nix index 15cf319..5a00eb6 100644 --- a/flake.nix +++ b/flake.nix @@ -29,6 +29,7 @@ packages = with pkgs; [ sqlx-cli + cargo-nextest (rust-bin.stable.latest.default.override { extensions = [ "rust-src" "rust-analyzer" ]; targets = [ ]; diff --git a/migrations/20250410112325_add_follow.sql b/migrations/20250410112325_add_follow.sql index ce518cd..4e8922b 100644 --- a/migrations/20250410112325_add_follow.sql +++ b/migrations/20250410112325_add_follow.sql @@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS follow id TEXT PRIMARY KEY NOT NULL, follower_id TEXT NOT NULL, followed_id TEXT NOT NULL, + FOREIGN KEY(follower_id) REFERENCES actor(id), FOREIGN KEY(followed_id) REFERENCES actor(id) );