diff --git a/ferri-cli/src/main.rs b/ferri-cli/src/main.rs index e785c88..b67ad9e 100644 --- a/ferri-cli/src/main.rs +++ b/ferri-cli/src/main.rs @@ -1,4 +1,3 @@ -use main::types::{db, make, ObjectUri, ObjectUuid}; use server::launch; extern crate rocket; @@ -25,10 +24,6 @@ pub fn read_config(path: impl AsRef) -> config::Config { toml::from_str(&content).unwrap() } -fn s(st: &'static str) -> String { - st.to_string() -} - #[rocket::main] async fn main() { let cli = Cli::parse(); @@ -41,32 +36,42 @@ async fn main() { .unwrap(); let mut conn = pool.acquire().await.unwrap(); - let actor = db::Actor { - id: ObjectUri(s("https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9")), - inbox: s("https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9/inbox"), - outbox: s("https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9/outbox") - }; + sqlx::query!( + r#" + INSERT INTO actor (id, inbox, outbox) + VALUES (?1, ?2, ?3) + "#, + "https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9", + "https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9/inbox", + "https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9/outbox" + ) + .execute(&mut *conn) + .await + .unwrap(); - make::new_actor(actor.clone(), &mut *conn).await.unwrap(); + let ts = main::ap::new_ts(); - let user = db::User { - id: ObjectUuid(s("9b9d497b-2731-435f-a929-e609ca69dac9")), - actor, - username: s("amy"), - display_name: s("amy (display)"), - acct: s("amy"), - remote: false, - url: s("https://ferri.amy.mov/@amy"), - created_at: main::now(), - icon_url: s("https://ferri.amy.mov/assets/pfp.png"), - posts: db::UserPosts { - last_post_at: None - }, - key_id: s("https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9#main-key") - - }; - - make::new_user(user, &mut *conn).await.unwrap(); + sqlx::query!( + r#" + INSERT INTO user ( + id, acct, url, remote, username, + actor_id, display_name, created_at, icon_url + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "#, + "9b9d497b-2731-435f-a929-e609ca69dac9", + "amy", + "https://ferri.amy.mov/@amy", + false, + "amy", + "https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9", + "amy", + ts, + "https://ferri.amy.mov/assets/pfp.png" + ) + .execute(&mut *conn) + .await + .unwrap(); } else { let _ = launch(config).launch().await; } diff --git a/ferri-main/src/ap/activity.rs b/ferri-main/src/ap/activity.rs new file mode 100644 index 0000000..80f45e7 --- /dev/null +++ b/ferri-main/src/ap/activity.rs @@ -0,0 +1,141 @@ +use crate::ap::{Actor, User, http}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::Sqlite; +use std::fmt::Debug; +use tracing::{Level, event}; + +#[derive(Debug, Clone)] +pub enum ActivityType { + Follow, + Accept, + Create, + Unknown, +} + +impl ActivityType { + fn to_raw(self) -> String { + match self { + ActivityType::Follow => "Follow".to_string(), + ActivityType::Accept => "Accept".to_string(), + ActivityType::Create => "Create".to_string(), + ActivityType::Unknown => "FIXME".to_string(), + } + } +} + +#[derive(Debug, Clone)] +pub struct Activity { + pub id: String, + pub ty: ActivityType, + pub object: T, + pub published: DateTime, + pub to: Vec, + pub cc: Vec, +} + +impl Default for Activity { + fn default() -> Self { + Self { + id: Default::default(), + ty: ActivityType::Unknown, + object: Default::default(), + published: Utc::now(), + to: Default::default(), + cc: Default::default(), + } + } +} + +pub type KeyId = String; + +#[derive(Debug, Clone)] +pub struct OutgoingActivity { + pub signed_by: KeyId, + pub req: Activity, + pub to: Actor, +} + +impl OutgoingActivity { + pub async fn save(&self, conn: impl sqlx::Executor<'_, Database = Sqlite>) { + let ty = self.req.ty.clone().to_raw(); + let actor_id = self.to.id(); + + sqlx::query!( + r#" + INSERT INTO activity (id, ty, actor_id) + VALUES (?1, ?2, ?3) + "#, + self.req.id, + ty, + actor_id + ) + .execute(conn) + .await + .unwrap(); + } +} + +#[derive(Serialize, Deserialize, Debug)] +struct RawActivity { + #[serde(rename = "@context")] + #[serde(skip_deserializing)] + context: String, + + id: String, + #[serde(rename = "type")] + ty: String, + + actor: String, + object: T, + published: String, +} + +type OutboxTransport = http::HttpClient; +pub struct Outbox<'a> { + user: User, + transport: &'a OutboxTransport, +} + +impl<'a> Outbox<'a> { + pub fn user(&self) -> &User { + &self.user + } + + pub async fn post(&self, activity: OutgoingActivity) { + event!(Level::INFO, ?activity, "activity in outbox"); + + let raw = RawActivity { + context: "https://www.w3.org/ns/activitystreams".to_string(), + id: activity.req.id.clone(), + ty: activity.req.ty.to_raw(), + actor: self.user.actor().id().to_string(), + object: activity.req.object, + published: activity.req.published.to_rfc3339(), + }; + + let outbox_res = self + .transport + .post(activity.to.inbox()) + .activity() + .json(&raw) + .sign(&activity.signed_by) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + event!( + Level::DEBUG, + outbox_res, + activity = activity.req.id, + "got response for outbox dispatch" + ); + } + + pub fn for_user(user: User, transport: &'a OutboxTransport) -> Outbox<'a> { + Outbox { user, transport } + } +} diff --git a/ferri-main/src/ap/http.rs b/ferri-main/src/ap/http.rs new file mode 100644 index 0000000..5b8f20b --- /dev/null +++ b/ferri-main/src/ap/http.rs @@ -0,0 +1,199 @@ +use reqwest::{IntoUrl, Response}; +use serde::Serialize; +use url::Url; + +use rsa::{ + RsaPrivateKey, + pkcs1v15::SigningKey, + pkcs8::DecodePrivateKey, + sha2::{Digest, Sha256}, + signature::{RandomizedSigner, SignatureEncoding}, +}; + +use base64::prelude::*; +use chrono::Utc; +use tracing::{Level, event}; + +pub struct HttpClient { + client: reqwest::Client, +} + +#[derive(Debug)] +pub struct PostSignature { + date: String, + digest: String, + signature: String, +} + +#[derive(Debug)] +struct GetSignature { + date: String, + signature: String, +} + +enum RequestVerb { + GET, + POST, +} + +pub struct RequestBuilder { + verb: RequestVerb, + url: Url, + body: String, + inner: reqwest::RequestBuilder, +} + +impl RequestBuilder { + pub fn json(mut self, json: impl Serialize + Sized) -> RequestBuilder { + let body = serde_json::to_string(&json).unwrap(); + self.inner = self.inner.body(body.clone()); + self.body = body; + self + } + + pub fn activity(mut self) -> RequestBuilder { + self.inner = self + .inner + .header("Content-Type", "application/activity+json") + .header("Accept", "application/activity+json"); + self + } + + pub async fn send(self) -> Result { + event!(Level::DEBUG, ?self.inner, "sending an http request"); + + self.inner.send().await + } + + pub fn sign(mut self, key_id: &str) -> RequestBuilder { + match self.verb { + RequestVerb::GET => { + let sig = self.sign_get_request(key_id); + self.inner = self + .inner + .header("Date", sig.date) + .header("Signature", sig.signature); + self + } + RequestVerb::POST => { + let sig = self.sign_post_request(key_id); + self.inner = self + .inner + .header("Date", sig.date) + .header("Digest", sig.digest) + .header("Signature", sig.signature); + self + } + } + } + + fn sign_get_request(&self, key_id: &str) -> GetSignature { + let url = &self.url; + let host = url.host_str().unwrap(); + let path = url.path(); + + let private_key = + RsaPrivateKey::from_pkcs8_pem(include_str!("../../../private.pem")).unwrap(); + let signing_key = SigningKey::::new(private_key); + + // UTC=GMT for our purposes, use it + // RFC7231 is hardcoded to use GMT for.. some reason + let ts = Utc::now(); + + // RFC7231 string + let date = ts.format("%a, %d %b %Y %H:%M:%S GMT").to_string(); + + let to_sign = format!( + "(request-target): get {}\nhost: {}\ndate: {}", + path, host, date + ); + + let signature = signing_key.sign_with_rng(&mut rand::rngs::OsRng, &to_sign.into_bytes()); + let header = format!( + "keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{}\"", + key_id, + BASE64_STANDARD.encode(signature.to_bytes()) + ); + + GetSignature { + date, + signature: header, + } + } + + fn sign_post_request(&self, key_id: &str) -> PostSignature { + let body = &self.body; + let url = &self.url; + + let host = url.host_str().unwrap(); + let path = url.path(); + + let private_key = + RsaPrivateKey::from_pkcs8_pem(include_str!("../../../private.pem")).unwrap(); + let signing_key = SigningKey::::new(private_key); + + let mut hasher = Sha256::new(); + hasher.update(body); + let sha256 = hasher.finalize(); + + let b64 = BASE64_STANDARD.encode(sha256); + let digest = format!("SHA-256={}", b64); + + // UTC=GMT for our purposes, use it + // RFC7231 is hardcoded to use GMT for.. some reason + let ts = Utc::now(); + + // RFC7231 string + let date = ts.format("%a, %d %b %Y %H:%M:%S GMT").to_string(); + + let to_sign = format!( + "(request-target): post {}\nhost: {}\ndate: {}\ndigest: {}", + path, host, date, digest + ); + + let signature = signing_key.sign_with_rng(&mut rand::rngs::OsRng, &to_sign.into_bytes()); + let header = format!( + "keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{}\"", + key_id, + BASE64_STANDARD.encode(signature.to_bytes()) + ); + + PostSignature { + date, + digest, + signature: header, + } + } +} + +impl Default for HttpClient { + fn default() -> Self { + Self::new() + } +} + +impl HttpClient { + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } + + pub fn get(&self, url: impl IntoUrl + Clone) -> RequestBuilder { + RequestBuilder { + verb: RequestVerb::GET, + url: url.clone().into_url().unwrap(), + body: String::new(), + inner: self.client.get(url), + } + } + + pub fn post(&self, url: impl IntoUrl + Clone) -> RequestBuilder { + RequestBuilder { + verb: RequestVerb::POST, + url: url.clone().into_url().unwrap(), + body: String::new(), + inner: self.client.post(url), + } + } +} diff --git a/ferri-main/src/ap/mod.rs b/ferri-main/src/ap/mod.rs new file mode 100644 index 0000000..98d7330 --- /dev/null +++ b/ferri-main/src/ap/mod.rs @@ -0,0 +1,30 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +pub mod http; + +mod activity; +pub use activity::*; + +mod user; +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 { + Uuid::new_v4().to_string() +} + +pub fn new_ts() -> String { + now().to_rfc3339() +} + +pub fn now() -> DateTime { + Utc::now() +} diff --git a/ferri-main/src/ap/post.rs b/ferri-main/src/ap/post.rs new file mode 100644 index 0000000..a42a246 --- /dev/null +++ b/ferri-main/src/ap/post.rs @@ -0,0 +1,113 @@ +use crate::ap; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::Sqlite; + +const POST_TYPE: &str = "Note"; + +#[derive(Clone)] +pub struct Post { + id: String, + from: ap::User, + ts: DateTime, + content: String, + + to: Vec, + cc: Vec, +} + +impl Post { + pub fn from_parts(id: String, content: String, from: ap::User) -> Self { + Self { + id, + content, + from, + ts: ap::now(), + to: vec![], + cc: vec![], + } + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn content(&self) -> &str { + &self.content + } + + pub fn created_at(&self) -> String { + self.ts.to_rfc3339() + } + + pub fn uri(&self) -> String { + format!( + "https://ferri.amy.mov/users/{}/posts/{}", + self.from.id(), + self.id + ) + } + + pub async fn save(&self, conn: impl sqlx::Executor<'_, Database = Sqlite>) { + let ts = self.ts.to_rfc3339(); + let user_id = self.from.id(); + let post_id = self.id(); + let uri = self.uri(); + let content = self.content.clone(); + + sqlx::query!( + r#" + INSERT INTO post (id, uri, user_id, content, created_at) + VALUES (?1, ?2, ?3, ?4, ?5) + "#, + post_id, + uri, + user_id, + content, + ts + ) + .execute(conn) + .await + .unwrap(); + } + + pub fn to(mut self, recipient: String) -> Self { + self.to.push(recipient); + self + } + + pub fn cc(mut self, recipient: String) -> Self { + self.cc.push(recipient); + self + } + + pub fn to_ap(self) -> APPost { + APPost { + context: ap::AS_CONTEXT.to_string(), + id: self.uri(), + ty: POST_TYPE.to_string(), + ts: self.ts.to_rfc3339(), + content: self.content, + to: self.to, + cc: self.cc, + } + } +} + +#[derive(Serialize, Debug, Default)] +pub struct APPost { + #[serde(rename = "@context")] + #[serde(skip_deserializing)] + context: String, + id: String, + + #[serde(rename = "type")] + ty: String, + + #[serde(rename = "published")] + ts: String, + + content: String, + to: Vec, + cc: Vec, +} diff --git a/ferri-main/src/ap/request_queue.rs b/ferri-main/src/ap/request_queue.rs new file mode 100644 index 0000000..fc5ebeb --- /dev/null +++ b/ferri-main/src/ap/request_queue.rs @@ -0,0 +1,55 @@ +use std::sync::mpsc; +use std::thread; +use tracing::{Level, info, span}; + +#[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() { + // 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(); + + info!(?req, "got a message into the queue"); + + drop(_enter); + } + }); + + QueueHandle { send: self.send } + } +} diff --git a/ferri-main/src/ap/user.rs b/ferri-main/src/ap/user.rs new file mode 100644 index 0000000..3edd614 --- /dev/null +++ b/ferri-main/src/ap/user.rs @@ -0,0 +1,154 @@ +use sqlx::Sqlite; +use std::fmt::Debug; +use thiserror::Error; + +#[derive(Debug, Clone)] +pub struct Actor { + id: String, + inbox: String, + outbox: String, +} + +impl Actor { + pub fn from_raw(id: String, inbox: String, outbox: String) -> Self { + Self { id, inbox, outbox } + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn inbox(&self) -> &str { + &self.inbox + } + + pub fn outbox(&self) -> &str { + &self.outbox + } +} + +#[derive(Debug, Clone)] +pub struct User { + id: String, + username: String, + actor: Actor, + display_name: String, +} + +#[derive(Error, Debug)] +pub enum UserError { + #[error("user `{0}` not found")] + NotFound(String), +} + +impl User { + pub fn id(&self) -> &str { + &self.id + } + + pub fn username(&self) -> &str { + &self.username + } + + pub fn actor_id(&self) -> &str { + &self.actor.id + } + + pub fn display_name(&self) -> &str { + &self.display_name + } + + pub fn actor(&self) -> &Actor { + &self.actor + } + + pub fn uri(&self) -> String { + format!("https://ferri.amy.mov/users/{}", self.id()) + } + + pub async fn from_id( + uuid: &str, + conn: impl sqlx::Executor<'_, Database = Sqlite>, + ) -> Result { + let user = sqlx::query!( + r#" + SELECT u.*, a.id as "actor_own_id", a.inbox, a.outbox + FROM user u + INNER JOIN actor a ON u.actor_id = a.id + WHERE u.id = ?1 + "#, + uuid + ) + .fetch_one(conn) + .await + .map_err(|_| UserError::NotFound(uuid.to_string()))?; + + Ok(User { + id: user.id, + username: user.username, + actor: Actor { + id: user.actor_own_id, + inbox: user.inbox, + outbox: user.outbox, + }, + display_name: user.display_name, + }) + } + + pub async fn from_username( + username: &str, + conn: impl sqlx::Executor<'_, Database = Sqlite>, + ) -> User { + let user = sqlx::query!( + r#" + SELECT u.*, a.id as "actor_own_id", a.inbox, a.outbox + FROM user u + INNER JOIN actor a ON u.actor_id = a.id + WHERE username = ?1 + "#, + username + ) + .fetch_one(conn) + .await + .unwrap(); + + User { + id: user.id, + username: user.username, + actor: Actor { + id: user.actor_own_id, + inbox: user.inbox, + outbox: user.outbox, + }, + display_name: user.display_name, + } + } + + pub async fn from_actor_id( + actor_id: &str, + conn: impl sqlx::Executor<'_, Database = Sqlite>, + ) -> User { + let user = sqlx::query!( + r#" + SELECT u.*, a.id as "actor_own_id", a.inbox, a.outbox + FROM user u + INNER JOIN actor a ON u.actor_id = a.id + WHERE actor_id = ?1 + "#, + actor_id + ) + .fetch_one(conn) + .await + .unwrap(); + User { + id: user.id, + username: user.username, + actor: Actor { + id: user.actor_own_id, + inbox: user.inbox, + outbox: user.outbox, + }, + display_name: user.display_name, + } + } +} diff --git a/ferri-main/src/federation/http.rs b/ferri-main/src/federation/http.rs index 65fcc52..d6e592d 100644 --- a/ferri-main/src/federation/http.rs +++ b/ferri-main/src/federation/http.rs @@ -1,23 +1,10 @@ +use crate::ap::http::HttpClient; use crate::types::ap; use std::fmt::Debug; use serde::Serialize; use thiserror::Error; use tracing::{Level, error, event, info}; -use reqwest::{IntoUrl, Response}; -use url::Url; - -use rsa::{ - RsaPrivateKey, - pkcs1v15::SigningKey, - pkcs8::DecodePrivateKey, - sha2::{Digest, Sha256}, - signature::{RandomizedSigner, SignatureEncoding}, -}; - -use base64::prelude::*; -use chrono::Utc; - use super::outbox::PreparedActivity; pub struct HttpWrapper<'a> { @@ -121,187 +108,3 @@ impl<'a> HttpWrapper<'a> { Ok(raw_body.to_string()) } } - -pub struct HttpClient { - client: reqwest::Client, -} - -#[derive(Debug)] -pub struct PostSignature { - date: String, - digest: String, - signature: String, -} - -#[derive(Debug)] -struct GetSignature { - date: String, - signature: String, -} - -enum RequestVerb { - GET, - POST, -} - -pub struct RequestBuilder { - verb: RequestVerb, - url: Url, - body: String, - inner: reqwest::RequestBuilder, -} - -impl RequestBuilder { - pub fn json(mut self, json: impl Serialize + Sized) -> RequestBuilder { - let body = serde_json::to_string(&json).unwrap(); - self.inner = self.inner.body(body.clone()); - self.body = body; - self - } - - pub fn activity(mut self) -> RequestBuilder { - self.inner = self - .inner - .header("Content-Type", "application/activity+json") - .header("Accept", "application/activity+json"); - self - } - - pub async fn send(self) -> Result { - event!(Level::DEBUG, ?self.inner, "sending an http request"); - - self.inner.send().await - } - - pub fn sign(mut self, key_id: &str) -> RequestBuilder { - match self.verb { - RequestVerb::GET => { - let sig = self.sign_get_request(key_id); - self.inner = self - .inner - .header("Date", sig.date) - .header("Signature", sig.signature); - self - } - RequestVerb::POST => { - let sig = self.sign_post_request(key_id); - self.inner = self - .inner - .header("Date", sig.date) - .header("Digest", sig.digest) - .header("Signature", sig.signature); - self - } - } - } - - fn sign_get_request(&self, key_id: &str) -> GetSignature { - let url = &self.url; - let host = url.host_str().unwrap(); - let path = url.path(); - - let private_key = - RsaPrivateKey::from_pkcs8_pem(include_str!("../../../private.pem")).unwrap(); - let signing_key = SigningKey::::new(private_key); - - // UTC=GMT for our purposes, use it - // RFC7231 is hardcoded to use GMT for.. some reason - let ts = Utc::now(); - - // RFC7231 string - let date = ts.format("%a, %d %b %Y %H:%M:%S GMT").to_string(); - - let to_sign = format!( - "(request-target): get {}\nhost: {}\ndate: {}", - path, host, date - ); - - let signature = signing_key.sign_with_rng(&mut rand::rngs::OsRng, &to_sign.into_bytes()); - let header = format!( - "keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{}\"", - key_id, - BASE64_STANDARD.encode(signature.to_bytes()) - ); - - GetSignature { - date, - signature: header, - } - } - - fn sign_post_request(&self, key_id: &str) -> PostSignature { - let body = &self.body; - let url = &self.url; - - let host = url.host_str().unwrap(); - let path = url.path(); - - let private_key = - RsaPrivateKey::from_pkcs8_pem(include_str!("../../../private.pem")).unwrap(); - let signing_key = SigningKey::::new(private_key); - - let mut hasher = Sha256::new(); - hasher.update(body); - let sha256 = hasher.finalize(); - - let b64 = BASE64_STANDARD.encode(sha256); - let digest = format!("SHA-256={}", b64); - - // UTC=GMT for our purposes, use it - // RFC7231 is hardcoded to use GMT for.. some reason - let ts = Utc::now(); - - // RFC7231 string - let date = ts.format("%a, %d %b %Y %H:%M:%S GMT").to_string(); - - let to_sign = format!( - "(request-target): post {}\nhost: {}\ndate: {}\ndigest: {}", - path, host, date, digest - ); - - let signature = signing_key.sign_with_rng(&mut rand::rngs::OsRng, &to_sign.into_bytes()); - let header = format!( - "keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{}\"", - key_id, - BASE64_STANDARD.encode(signature.to_bytes()) - ); - - PostSignature { - date, - digest, - signature: header, - } - } -} - -impl Default for HttpClient { - fn default() -> Self { - Self::new() - } -} - -impl HttpClient { - pub fn new() -> Self { - Self { - client: reqwest::Client::new(), - } - } - - pub fn get(&self, url: impl IntoUrl + Clone) -> RequestBuilder { - RequestBuilder { - verb: RequestVerb::GET, - url: url.clone().into_url().unwrap(), - body: String::new(), - inner: self.client.get(url), - } - } - - pub fn post(&self, url: impl IntoUrl + Clone) -> RequestBuilder { - RequestBuilder { - verb: RequestVerb::POST, - url: url.clone().into_url().unwrap(), - body: String::new(), - inner: self.client.post(url), - } - } -} diff --git a/ferri-main/src/federation/inbox.rs b/ferri-main/src/federation/inbox.rs index 23d6fd1..68dd742 100644 --- a/ferri-main/src/federation/inbox.rs +++ b/ferri-main/src/federation/inbox.rs @@ -1,6 +1,7 @@ use crate::types::{ap, as_context, db, get, make, Object, ObjectUri, ObjectUuid}; +use crate::ap::http::HttpClient; -use super::http::{HttpClient, HttpWrapper}; +use super::http::HttpWrapper; use super::outbox::OutboxRequest; use super::QueueMessage; @@ -82,7 +83,7 @@ pub async fn handle_inbox_request( let rmt = person.remote_info(); let post = activity.object; - let post_id = crate::new_id(); + let post_id = crate::ap::new_id(); let created_at = DateTime::parse_from_rfc3339(&activity.ts) .map(|dt| dt.to_utc()) @@ -103,25 +104,20 @@ pub async fn handle_inbox_request( let user = get::user_by_actor_uri(actor.id.clone(), &mut conn) .await .unwrap_or_else(|_| { - let id = crate::new_id(); db::User { - id: ObjectUuid(id.clone()), + id: ObjectUuid(crate::new_id()), actor, username: person.preferred_username, display_name: person.name, acct: rmt.acct, remote: rmt.is_remote, url: rmt.web_url, - created_at: crate::now(), + created_at: crate::ap::now(), icon_url: person.icon.map(|ic| ic.url) .unwrap_or("https//ferri.amy.mov/assets/pfp.png".to_string()), posts: db::UserPosts { last_post_at: None - }, - key_id: format!( - "https://ferri.amy.mov/users/{}#main-key", - id - ) + } } }); @@ -203,9 +199,8 @@ pub async fn handle_inbox_request( let user = get::user_by_actor_uri(actor.id.clone(), &mut conn) .await .unwrap_or_else(|_| { - let id = crate::new_id(); db::User { - id: ObjectUuid(id.clone()), + id: ObjectUuid(crate::new_id()), actor, username: boosted_author.preferred_username, display_name: boosted_author.name, @@ -213,16 +208,12 @@ pub async fn handle_inbox_request( remote: boosted_rmt.is_remote, url: boosted_rmt.web_url, // FIXME: Come from boosted_author - created_at: crate::now(), + created_at: crate::ap::now(), icon_url: boosted_author.icon.map(|ic| ic.url) .unwrap_or("https//ferri.amy.mov/assets/pfp.png".to_string()), posts: db::UserPosts { last_post_at: None - }, - key_id: format!( - "https://ferri.amy.mov/users/{}#main-key", - id - ) + } } }); @@ -279,25 +270,20 @@ pub async fn handle_inbox_request( let user = get::user_by_actor_uri(actor.id.clone(), &mut conn) .await .unwrap_or_else(|_| { - let id = crate::new_id(); db::User { - id: ObjectUuid(id.clone()), + id: ObjectUuid(crate::new_id()), actor, username: person.preferred_username, display_name: person.name, acct: rmt.acct, remote: rmt.is_remote, url: rmt.web_url, - created_at: crate::now(), + created_at: crate::ap::now(), icon_url: person.icon.map(|ic| ic.url) .unwrap_or("https//ferri.amy.mov/assets/pfp.png".to_string()), posts: db::UserPosts { last_post_at: None - }, - key_id: format!( - "https://ferri.amy.mov/users/{}#main-key", - id - ) + } } }); diff --git a/ferri-main/src/federation/outbox.rs b/ferri-main/src/federation/outbox.rs index 9a08af0..0ffa2ad 100644 --- a/ferri-main/src/federation/outbox.rs +++ b/ferri-main/src/federation/outbox.rs @@ -1,22 +1,14 @@ use serde::{Deserialize, Serialize}; -use sqlx::SqliteConnection; use tracing::info; use std::fmt::Debug; -use crate::{federation::http::HttpWrapper, types::{ap::{self, ActivityType}, as_context, db, make, Object, ObjectContext, ObjectUri}}; - -use super::http::HttpClient; +use crate::{ap::http::HttpClient, federation::http::HttpWrapper, types::{ap::{self, ActivityType}, as_context, db, Object, ObjectContext, ObjectUri}}; #[derive(Debug)] pub enum OutboxRequest { // FIXME: Make the String (key_id) nicer // Probably store it in the DB and pass a db::User here Accept(ap::AcceptActivity, String, ap::Person), - Status(db::Post, String), - Follow { - follower: db::User, - followed: db::User, - conn: SqliteConnection - } + Status(db::Post, String) } #[derive(Serialize, Deserialize, Debug)] @@ -48,7 +40,7 @@ pub async fn handle_outbox_request( ty: activity.ty, actor: activity.actor, object: activity.object, - published: crate::now_str(), + published: crate::ap::new_ts() }; let res = http @@ -88,7 +80,7 @@ pub async fn handle_outbox_request( attachment: vec![], attributed_to: Some(post.user.actor.id.0) }, - published: crate::now_str(), + published: crate::ap::new_ts() }; let res = http @@ -98,40 +90,5 @@ pub async fn handle_outbox_request( info!("status res {}", res); } - OutboxRequest::Follow { follower, followed, mut conn } => { - let follow = db::Follow { - id: ObjectUri(format!( - "https://ferri.amy.mov/activities/{}", - crate::new_id()) - ), - follower: follower.actor.id.clone(), - followed: followed.actor.id.clone(), - }; - - make::new_follow(follow, &mut conn) - .await - .unwrap(); - - let http = HttpWrapper::new(http, &follower.key_id); - - let activity = PreparedActivity { - context: as_context(), - id: format!( - "https://ferri.amy.mov/activities/{}", - crate::new_id() - ), - ty: ActivityType::Follow, - actor: follower.actor.id.0, - object: followed.actor.id.0, - published: crate::now_str(), - }; - - let res = http - .post_activity(&followed.actor.inbox, activity) - .await - .unwrap(); - - info!("follow res {}", res); - }, } } diff --git a/ferri-main/src/federation/request_queue.rs b/ferri-main/src/federation/request_queue.rs index 014af49..facf294 100644 --- a/ferri-main/src/federation/request_queue.rs +++ b/ferri-main/src/federation/request_queue.rs @@ -1,8 +1,8 @@ use tokio::sync::mpsc; use tracing::{info, span, Instrument, Level}; +use crate::ap::http::HttpClient; use crate::config::Config; -use crate::federation::http::HttpClient; use crate::federation::inbox::handle_inbox_request; use crate::federation::outbox::handle_outbox_request; diff --git a/ferri-main/src/lib.rs b/ferri-main/src/lib.rs index d18d33e..c9a8744 100644 --- a/ferri-main/src/lib.rs +++ b/ferri-main/src/lib.rs @@ -1,8 +1,8 @@ +pub mod ap; pub mod config; pub mod types; pub mod federation; -use chrono::{DateTime, Utc}; use rand::{Rng, distributions::Alphanumeric}; pub fn gen_token(len: usize) -> String { @@ -16,11 +16,3 @@ pub fn gen_token(len: usize) -> String { pub fn new_id() -> String { uuid::Uuid::new_v4().to_string() } - -pub fn now() -> DateTime { - Utc::now() -} - -pub fn now_str() -> String { - now().to_rfc3339() -} diff --git a/ferri-main/src/types/ap.rs b/ferri-main/src/types/ap.rs deleted file mode 100644 index a7a4ef2..0000000 --- a/ferri-main/src/types/ap.rs +++ /dev/null @@ -1,223 +0,0 @@ -use super::*; -use serde::{Deserialize, Serialize}; -use url::Url; - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub enum ActivityType { - Reject, - Create, - Note, - Delete, - Undo, - Accept, - Announce, - Person, - Like, - Follow, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct MinimalActivity { - #[serde(flatten)] - pub obj: Object, - - #[serde(rename = "type")] - pub ty: ActivityType, -} - -pub type DeleteActivity = BasicActivity; -pub type LikeActivity = BasicActivity; - -#[derive(Serialize, Deserialize, Debug)] -pub struct BasicActivity { - #[serde(flatten)] - pub obj: Object, - - pub object: String, - pub actor: String, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct CreateActivity { - #[serde(flatten)] - pub obj: Object, - - #[serde(rename = "type")] - pub ty: ActivityType, - - pub object: Post, - pub actor: String, - pub to: Vec, - pub cc: Vec, - - #[serde(rename = "published")] - pub ts: String, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct FollowActivity { - #[serde(flatten)] - pub obj: Object, - - #[serde(rename = "type")] - pub ty: ActivityType, - - pub object: String, - pub actor: String, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct AcceptActivity { - #[serde(flatten)] - pub obj: Object, - - #[serde(rename = "type")] - pub ty: ActivityType, - - pub object: String, - pub actor: String, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct BoostActivity { - #[serde(flatten)] - pub obj: Object, - - #[serde(rename = "type")] - pub ty: ActivityType, - - pub actor: String, - pub published: String, - pub to: Vec, - pub cc: Vec, - pub object: String, -} - -#[derive(Serialize, Deserialize, Debug)] -pub enum PostAttachmentType { - Document -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct PostAttachment { - #[serde(rename = "type")] - pub ty: PostAttachmentType, - - pub media_type: String, - pub url: String, - #[serde(deserialize_with = "deserialize_null_default")] - pub name: String, - - pub summary: Option, - #[serde(default)] - pub sensitive: bool -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct Post { - #[serde(flatten)] - pub obj: Object, - - #[serde(rename = "type")] - pub ty: ActivityType, - - #[serde(rename = "published")] - pub ts: String, - pub content: String, - pub to: Vec, - pub cc: Vec, - - pub attachment: Vec, - - #[serde(rename = "attributedTo")] - pub attributed_to: Option, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct Actor { - #[serde(flatten)] - pub obj: Object, - - pub inbox: String, - pub outbox: String, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub enum IconType { - Image -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct PersonIcon { - #[serde(rename = "type")] - pub ty: IconType, - pub url: String, - - #[serde(default)] - pub summary: String, - #[serde(default)] - pub width: i64, - #[serde(default)] - pub height: i64 -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Person { - #[serde(flatten)] - pub obj: Object, - - #[serde(rename = "type")] - pub ty: ActivityType, - - pub following: String, - pub followers: String, - - pub summary: String, - pub inbox: String, - pub outbox: String, - - pub preferred_username: String, - pub name: String, - - pub public_key: Option, - - pub icon: Option -} - -pub struct RemoteInfo { - pub is_remote: bool, - pub web_url: String, - pub acct: String -} - -impl Person { - pub fn remote_info(&self) -> RemoteInfo { - let url = Url::parse(&self.obj.id.0).unwrap(); - let host = url.host_str().unwrap(); - - let (acct, remote) = if host != "ferri.amy.mov" { - (format!("{}@{}", self.preferred_username, host), true) - } else { - (self.preferred_username.clone(), false) - }; - - let url = format!("https://ferri.amy.mov/{}", acct); - - RemoteInfo { - acct: acct.to_string(), - web_url: url, - is_remote: remote, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct UserKey { - pub id: String, - pub owner: String, - - #[serde(rename = "publicKeyPem")] - pub public_key: String, -} diff --git a/ferri-main/src/types/api.rs b/ferri-main/src/types/api.rs deleted file mode 100644 index ab71ead..0000000 --- a/ferri-main/src/types/api.rs +++ /dev/null @@ -1,249 +0,0 @@ -use super::*; -use serde::{Deserialize, Serialize}; - -// API will not really use actors so treat them as DB actors -// until we require specificity -pub type Actor = db::Actor; - -#[derive(Serialize, Deserialize, Debug)] -pub struct CredentialApplication { - pub name: String, - pub scopes: String, - pub redirect_uris: Vec, - pub client_id: String, - pub client_secret: String, -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct WebfingerLink { - pub rel: String, - #[serde(rename = "type")] - pub ty: Option, - pub href: Option, -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct WebfingerHit { - pub subject: String, - pub aliases: Vec, - pub links: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct StatusAttachment { - pub id: ObjectUuid, - #[serde(rename = "type")] - pub ty: String, - - pub url: String, - pub description: String -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct Status { - pub id: ObjectUuid, - pub created_at: String, - pub in_reply_to_id: Option, - pub in_reply_to_account_id: Option, - pub sensitive: bool, - pub spoiler_text: String, - pub visibility: String, - pub language: String, - pub uri: ObjectUri, - pub url: String, - pub replies_count: i64, - pub reblogs_count: i64, - pub favourites_count: i64, - pub favourited: bool, - pub reblogged: bool, - pub muted: bool, - pub bookmarked: bool, - pub content: String, - pub reblog: Option>, - pub application: Option<()>, - pub account: Account, - pub media_attachments: Vec, - pub mentions: Vec>, - pub tags: Vec>, - pub emojis: Vec>, - pub card: Option<()>, - pub poll: Option<()>, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct Relationship { - id: ObjectUuid, - following: bool, - showing_reblogs: bool, - notifying: bool, - followed_by: bool, - blocking: bool, - blocked_by: bool, - muting: bool, - muting_notifications: bool, - requested: bool, - requested_by: bool, - domain_blocking: bool, - endorsed: bool, - note: String -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct Account { - pub id: ObjectUuid, - pub username: String, - pub acct: String, - pub display_name: String, - - pub locked: bool, - pub bot: bool, - - pub created_at: String, - pub attribution_domains: Vec, - - pub note: String, - pub url: String, - - pub avatar: String, - pub avatar_static: String, - pub header: String, - pub header_static: String, - - pub followers_count: i64, - pub following_count: i64, - pub statuses_count: i64, - pub last_status_at: Option, - - pub emojis: Vec, - pub fields: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct Emoji { - pub shortcode: String, - pub url: String, - pub static_url: String, - pub visible_in_picker: bool, -} - -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct CustomField { - pub name: String, - pub value: String, - pub verified_at: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Instance { - pub domain: String, - pub title: String, - pub version: String, - pub source_url: String, - pub description: String, - pub thumbnail: Thumbnail, - pub icon: Vec, - pub languages: Vec, - pub configuration: Configuration, - pub registrations: Registrations, - pub contact: Contact, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Configuration { - pub urls: Urls, - pub accounts: Accounts, - pub statuses: Statuses, - pub media_attachments: MediaAttachments, - pub polls: Polls, - pub translation: Translation, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Accounts { - pub max_featured_tags: i64, - pub max_pinned_statuses: i64, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct MediaAttachments { - pub supported_mime_types: Vec, - pub description_limit: i64, - pub image_size_limit: i64, - pub image_matrix_limit: i64, - pub video_size_limit: i64, - pub video_frame_rate_limit: i64, - pub video_matrix_limit: i64, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Polls { - pub max_options: i64, - pub max_characters_per_option: i64, - pub min_expiration: i64, - pub max_expiration: i64, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Statuses { - pub max_characters: i64, - pub max_media_attachments: i64, - pub characters_reserved_per_url: i64, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Translation { - pub enabled: bool, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Urls { - pub streaming: String, - pub about: String, - pub privacy_policy: String, - pub terms_of_service: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Contact { - pub email: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Field { - pub name: String, - pub value: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Icon { - pub src: String, - pub size: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Registrations { - pub enabled: bool, - pub approval_required: bool, - pub reason_required: bool, - pub message: Option, - pub min_age: i64, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Thumbnail { - pub url: String, -} - -#[derive(Serialize, Deserialize)] -pub struct Preferences { - #[serde(rename = "posting:default:visibility")] - pub posting_default_visibility: String, - #[serde(rename = "posting:default:sensitive")] - pub posting_default_sensitive: bool, - #[serde(rename = "posting:default:language")] - pub posting_default_language: Option, - #[serde(rename = "reading:expand:media")] - pub reading_expand_media: String, - #[serde(rename = "reading:expand:spoilers")] - pub reading_expand_spoilers: bool, -} diff --git a/ferri-main/src/types/db.rs b/ferri-main/src/types/db.rs deleted file mode 100644 index 4b9dece..0000000 --- a/ferri-main/src/types/db.rs +++ /dev/null @@ -1,60 +0,0 @@ -use super::*; -use chrono::{DateTime, Utc}; - -#[derive(Debug, Eq, PartialEq, Clone)] -pub struct Follow { - pub id: ObjectUri, - pub follower: ObjectUri, - pub followed: ObjectUri, -} - -#[derive(Debug, Eq, PartialEq, Clone)] -pub struct Actor { - pub id: ObjectUri, - pub inbox: String, - pub outbox: String, -} - -#[derive(Debug, Eq, PartialEq, Clone)] -pub struct UserPosts { - // User may have no posts - pub last_post_at: Option>, -} - -#[derive(Debug, Eq, PartialEq, Clone)] -pub struct User { - pub id: ObjectUuid, - pub actor: Actor, - pub username: String, - pub display_name: String, - pub acct: String, - pub remote: bool, - pub url: String, - pub created_at: DateTime, - pub icon_url: String, - - pub posts: UserPosts, - pub key_id: String -} - -#[derive(Debug, Eq, PartialEq, Clone)] -pub struct Attachment { - pub id: ObjectUuid, - pub post_id: ObjectUuid, - pub url: String, - pub media_type: Option, - pub sensitive: bool, - pub alt: Option -} - -#[derive(Debug, Eq, PartialEq, Clone)] -pub struct Post { - pub id: ObjectUuid, - pub uri: ObjectUri, - pub user: User, - pub content: String, - pub created_at: DateTime, - pub boosted_post: Option>, - pub attachments: Vec -} - diff --git a/ferri-main/src/types/get.rs b/ferri-main/src/types/get.rs index acab381..0db4500 100644 --- a/ferri-main/src/types/get.rs +++ b/ferri-main/src/types/get.rs @@ -25,10 +25,7 @@ fn parse_ts(ts: String) -> Option> { Some(dt.unwrap()) } -pub async fn user_by_id( - id: ObjectUuid, - conn: &mut SqliteConnection -) -> Result { +pub async fn user_by_id(id: ObjectUuid, conn: &mut SqliteConnection) -> Result { info!("fetching user by uuid '{:?}' from the database", id); let record = sqlx::query!( @@ -92,7 +89,7 @@ pub async fn user_by_id( info!("user {:?} last posted {:?}", id, last_post_at); Ok(db::User { - id: ObjectUuid(record.user_id.clone()), + id: ObjectUuid(record.user_id), actor: db::Actor { id: ObjectUri(record.actor_id), inbox: record.inbox, @@ -105,99 +102,7 @@ pub async fn user_by_id( created_at: user_created, url: record.url, posts: db::UserPosts { last_post_at }, - icon_url: record.icon_url, - key_id: format!( - "https://ferri.amy.mov/users/{}#main-key", - record.user_id - ) - }) -} - -pub async fn user_by_username( - username: &str, - conn: &mut SqliteConnection -) -> Result { - info!("fetching user by username '{}' from the database", username); - - let record = sqlx::query!( - r#" - SELECT - u.id as "user_id", - u.username, - u.actor_id, - u.display_name, - a.inbox, - a.outbox, - u.url, - u.acct, - u.remote, - u.created_at, - u.icon_url - FROM "user" u - INNER JOIN "actor" a ON u.actor_id = a.id - WHERE u.username = ?1 - "#, - username - ) - .fetch_one(&mut *conn) - .await - .map_err(|e| DbError::FetchError(e.to_string()))?; - - let follower_count = sqlx::query_scalar!( - r#" - SELECT COUNT(follower_id) - FROM "follow" - WHERE followed_id = ?1 - "#, - record.actor_id - ) - .fetch_one(&mut *conn) - .await - .map_err(|e| DbError::FetchError(e.to_string()))?; - - let last_post_at = sqlx::query_scalar!( - r#" - SELECT datetime(p.created_at) - FROM post p - WHERE p.user_id = ?1 - ORDER BY datetime(p.created_at) DESC - LIMIT 1 - "#, - record.user_id - ) - .fetch_optional(&mut *conn) - .await - .map_err(|e| DbError::FetchError(e.to_string()))? - .flatten() - .and_then(|ts| { - info!("parsing timestamp {}", ts); - parse_ts(ts) - }); - - let user_created = parse_ts(record.created_at).expect("no db corruption"); - - info!("user {} has {} followers", record.user_id, follower_count); - info!("user {} last posted {:?}", record.user_id, last_post_at); - - Ok(db::User { - id: ObjectUuid(record.user_id.clone()), - actor: db::Actor { - id: ObjectUri(record.actor_id), - inbox: record.inbox, - outbox: record.outbox, - }, - acct: record.acct, - remote: record.remote, - username: record.username, - display_name: record.display_name, - created_at: user_created, - url: record.url, - posts: db::UserPosts { last_post_at }, - icon_url: record.icon_url, - key_id: format!( - "https://ferri.amy.mov/users/{}#main-key", - record.user_id - ) + icon_url: record.icon_url }) } @@ -265,7 +170,7 @@ pub async fn user_by_actor_uri(uri: ObjectUri, conn: &mut SqliteConnection) -> R info!("user {:?} last posted {:?}", record.user_id, last_post_at); Ok(db::User { - id: ObjectUuid(record.user_id.clone()), + id: ObjectUuid(record.user_id), actor: db::Actor { id: ObjectUri(record.actor_id), inbox: record.inbox, @@ -278,11 +183,7 @@ pub async fn user_by_actor_uri(uri: ObjectUri, conn: &mut SqliteConnection) -> R created_at: user_created, url: record.url, posts: db::UserPosts { last_post_at }, - icon_url: record.icon_url, - key_id: format!( - "https://ferri.amy.mov/users/{}#main-key", - record.user_id - ) + icon_url: record.icon_url }) } @@ -347,7 +248,7 @@ pub async fn posts_for_user_id( id: ObjectUuid(record.post_id), uri: ObjectUri(record.post_uri), user: db::User { - id: ObjectUuid(record.user_id.clone()), + id: ObjectUuid(record.user_id), actor: db::Actor { id: ObjectUri(record.actor_id), inbox: record.inbox, @@ -362,11 +263,7 @@ pub async fn posts_for_user_id( icon_url: record.icon_url, posts: db::UserPosts { last_post_at: None - }, - key_id: format!( - "https://ferri.amy.mov/users/{}#main-key", - record.user_id - ) + } }, attachments, content: record.content, @@ -408,7 +305,7 @@ pub async fn home_timeline( id: ObjectUuid(p.post_id), uri: ObjectUri(p.post_uri), user: db::User { - id: ObjectUuid(p.user_id.clone()), + id: ObjectUuid(p.user_id), actor: db::Actor { id: ObjectUri(p.actor_id), inbox: p.inbox, @@ -423,11 +320,7 @@ pub async fn home_timeline( icon_url: p.icon_url, posts: db::UserPosts { last_post_at: None - }, - key_id: format!( - "https://ferri.amy.mov/users/{}#main-key", - p.user_id - ) + } }, content: p.content, created_at: parse_ts(p.post_created).unwrap(), @@ -498,53 +391,3 @@ pub async fn home_timeline( Ok(out) } - -pub async fn followers_for_user( - user_id: ObjectUuid, - conn: &mut SqliteConnection -) -> Result, DbError> { - let followers = sqlx::query!( - "SELECT * FROM follow WHERE followed_id = ?", - user_id.0 - ) - .fetch_all(&mut *conn) - .await - .unwrap(); - - let followers = followers.into_iter() - .map(|f| { - db::Follow { - id: ObjectUri(f.id), - follower: ObjectUri(f.follower_id), - followed: ObjectUri(f.followed_id) - } - }) - .collect::>(); - - Ok(followers) -} - -pub async fn following_for_user( - user_id: ObjectUuid, - conn: &mut SqliteConnection -) -> Result, DbError> { - let followers = sqlx::query!( - "SELECT * FROM follow WHERE follower_id = ?", - user_id.0 - ) - .fetch_all(&mut *conn) - .await - .unwrap(); - - let followers = followers.into_iter() - .map(|f| { - db::Follow { - id: ObjectUri(f.id), - follower: ObjectUri(f.follower_id), - followed: ObjectUri(f.followed_id) - } - }) - .collect::>(); - - Ok(followers) -} diff --git a/ferri-main/src/types/mod.rs b/ferri-main/src/types/mod.rs index 61487dd..35c4a43 100644 --- a/ferri-main/src/types/mod.rs +++ b/ferri-main/src/types/mod.rs @@ -7,10 +7,6 @@ pub mod convert; pub mod get; pub mod make; -pub mod db; -pub mod ap; -pub mod api; - fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result where T: Default + Deserialize<'de>, @@ -71,3 +67,540 @@ pub struct Object { pub context: ObjectContext, pub id: ObjectUri, } + +pub mod db { + use super::*; + use chrono::{DateTime, Utc}; + + #[derive(Debug, Eq, PartialEq, Clone)] + pub struct Follow { + pub id: ObjectUri, + pub follower: ObjectUri, + pub followed: ObjectUri, + } + + #[derive(Debug, Eq, PartialEq, Clone)] + pub struct Actor { + pub id: ObjectUri, + pub inbox: String, + pub outbox: String, + } + + #[derive(Debug, Eq, PartialEq, Clone)] + pub struct UserPosts { + // User may have no posts + pub last_post_at: Option>, + } + + #[derive(Debug, Eq, PartialEq, Clone)] + pub struct User { + pub id: ObjectUuid, + pub actor: Actor, + pub username: String, + pub display_name: String, + pub acct: String, + pub remote: bool, + pub url: String, + pub created_at: DateTime, + pub icon_url: String, + + pub posts: UserPosts, + } + + #[derive(Debug, Eq, PartialEq, Clone)] + pub struct Attachment { + pub id: ObjectUuid, + pub post_id: ObjectUuid, + pub url: String, + pub media_type: Option, + pub sensitive: bool, + pub alt: Option + } + + #[derive(Debug, Eq, PartialEq, Clone)] + pub struct Post { + pub id: ObjectUuid, + pub uri: ObjectUri, + pub user: User, + pub content: String, + pub created_at: DateTime, + pub boosted_post: Option>, + pub attachments: Vec + } +} + +pub mod ap { + use super::*; + use serde::{Deserialize, Serialize}; + use url::Url; + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub enum ActivityType { + Reject, + Create, + Note, + Delete, + Undo, + Accept, + Announce, + Person, + Like, + Follow, + } + + #[derive(Serialize, Deserialize, Debug)] + pub struct MinimalActivity { + #[serde(flatten)] + pub obj: Object, + + #[serde(rename = "type")] + pub ty: ActivityType, + } + + pub type DeleteActivity = BasicActivity; + pub type LikeActivity = BasicActivity; + + #[derive(Serialize, Deserialize, Debug)] + pub struct BasicActivity { + #[serde(flatten)] + pub obj: Object, + + pub object: String, + pub actor: String, + } + + #[derive(Serialize, Deserialize, Debug)] + pub struct CreateActivity { + #[serde(flatten)] + pub obj: Object, + + #[serde(rename = "type")] + pub ty: ActivityType, + + pub object: Post, + pub actor: String, + pub to: Vec, + pub cc: Vec, + + #[serde(rename = "published")] + pub ts: String, + } + + #[derive(Serialize, Deserialize, Debug)] + pub struct FollowActivity { + #[serde(flatten)] + pub obj: Object, + + #[serde(rename = "type")] + pub ty: ActivityType, + + pub object: String, + pub actor: String, + } + + #[derive(Serialize, Deserialize, Debug)] + pub struct AcceptActivity { + #[serde(flatten)] + pub obj: Object, + + #[serde(rename = "type")] + pub ty: ActivityType, + + pub object: String, + pub actor: String, + } + + #[derive(Serialize, Deserialize, Debug)] + pub struct BoostActivity { + #[serde(flatten)] + pub obj: Object, + + #[serde(rename = "type")] + pub ty: ActivityType, + + pub actor: String, + pub published: String, + pub to: Vec, + pub cc: Vec, + pub object: String, + } + + #[derive(Serialize, Deserialize, Debug)] + pub enum PostAttachmentType { + Document + } + + #[derive(Serialize, Deserialize, Debug)] + #[serde(rename_all = "camelCase")] + pub struct PostAttachment { + #[serde(rename = "type")] + pub ty: PostAttachmentType, + + pub media_type: String, + pub url: String, + #[serde(deserialize_with = "deserialize_null_default")] + pub name: String, + + pub summary: Option, + #[serde(default)] + pub sensitive: bool + } + + #[derive(Serialize, Deserialize, Debug)] + pub struct Post { + #[serde(flatten)] + pub obj: Object, + + #[serde(rename = "type")] + pub ty: ActivityType, + + #[serde(rename = "published")] + pub ts: String, + pub content: String, + pub to: Vec, + pub cc: Vec, + + pub attachment: Vec, + + #[serde(rename = "attributedTo")] + pub attributed_to: Option, + } + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub struct Actor { + #[serde(flatten)] + pub obj: Object, + + pub inbox: String, + pub outbox: String, + } + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub enum IconType { + Image + } + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub struct PersonIcon { + #[serde(rename = "type")] + pub ty: IconType, + pub url: String, + + #[serde(default)] + pub summary: String, + #[serde(default)] + pub width: i64, + #[serde(default)] + pub height: i64 + } + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + #[serde(rename_all = "camelCase")] + pub struct Person { + #[serde(flatten)] + pub obj: Object, + + #[serde(rename = "type")] + pub ty: ActivityType, + + pub following: String, + pub followers: String, + + pub summary: String, + pub inbox: String, + pub outbox: String, + + pub preferred_username: String, + pub name: String, + + pub public_key: Option, + + pub icon: Option + } + + pub struct RemoteInfo { + pub is_remote: bool, + pub web_url: String, + pub acct: String + } + + impl Person { + pub fn remote_info(&self) -> RemoteInfo { + let url = Url::parse(&self.obj.id.0).unwrap(); + let host = url.host_str().unwrap(); + + let (acct, remote) = if host != "ferri.amy.mov" { + (format!("{}@{}", self.preferred_username, host), true) + } else { + (self.preferred_username.clone(), false) + }; + + let url = format!("https://ferri.amy.mov/{}", acct); + + RemoteInfo { + acct: acct.to_string(), + web_url: url, + is_remote: remote, + } + } + } + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub struct UserKey { + pub id: String, + pub owner: String, + + #[serde(rename = "publicKeyPem")] + pub public_key: String, + } +} + +pub mod api { + use super::*; + use serde::{Deserialize, Serialize}; + + // API will not really use actors so treat them as DB actors + // until we require specificity + pub type Actor = db::Actor; + + #[derive(Serialize, Deserialize, Debug)] + pub struct CredentialApplication { + pub name: String, + pub scopes: String, + pub redirect_uris: Vec, + pub client_id: String, + pub client_secret: String, + } + + #[derive(Deserialize, Serialize, Debug)] + pub struct WebfingerLink { + pub rel: String, + #[serde(rename = "type")] + pub ty: Option, + pub href: Option, + } + + #[derive(Deserialize, Serialize, Debug)] + pub struct WebfingerHit { + pub subject: String, + pub aliases: Vec, + pub links: Vec, + } + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub struct StatusAttachment { + pub id: ObjectUuid, + #[serde(rename = "type")] + pub ty: String, + + pub url: String, + pub description: String + } + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub struct Status { + pub id: ObjectUuid, + pub created_at: String, + pub in_reply_to_id: Option, + pub in_reply_to_account_id: Option, + pub sensitive: bool, + pub spoiler_text: String, + pub visibility: String, + pub language: String, + pub uri: ObjectUri, + pub url: String, + pub replies_count: i64, + pub reblogs_count: i64, + pub favourites_count: i64, + pub favourited: bool, + pub reblogged: bool, + pub muted: bool, + pub bookmarked: bool, + pub content: String, + pub reblog: Option>, + pub application: Option<()>, + pub account: Account, + pub media_attachments: Vec, + pub mentions: Vec>, + pub tags: Vec>, + pub emojis: Vec>, + pub card: Option<()>, + pub poll: Option<()>, + } + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub struct Account { + pub id: ObjectUuid, + pub username: String, + pub acct: String, + pub display_name: String, + + pub locked: bool, + pub bot: bool, + + pub created_at: String, + pub attribution_domains: Vec, + + pub note: String, + pub url: String, + + pub avatar: String, + pub avatar_static: String, + pub header: String, + pub header_static: String, + + pub followers_count: i64, + pub following_count: i64, + pub statuses_count: i64, + pub last_status_at: Option, + + pub emojis: Vec, + pub fields: Vec, + } + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub struct Emoji { + pub shortcode: String, + pub url: String, + pub static_url: String, + pub visible_in_picker: bool, + } + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub struct CustomField { + pub name: String, + pub value: String, + pub verified_at: Option, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct Instance { + pub domain: String, + pub title: String, + pub version: String, + pub source_url: String, + pub description: String, + pub thumbnail: Thumbnail, + pub icon: Vec, + pub languages: Vec, + pub configuration: Configuration, + pub registrations: Registrations, + pub contact: Contact, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct Configuration { + pub urls: Urls, + pub accounts: Accounts, + pub statuses: Statuses, + pub media_attachments: MediaAttachments, + pub polls: Polls, + pub translation: Translation, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct Accounts { + pub max_featured_tags: i64, + pub max_pinned_statuses: i64, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct MediaAttachments { + pub supported_mime_types: Vec, + pub description_limit: i64, + pub image_size_limit: i64, + pub image_matrix_limit: i64, + pub video_size_limit: i64, + pub video_frame_rate_limit: i64, + pub video_matrix_limit: i64, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct Polls { + pub max_options: i64, + pub max_characters_per_option: i64, + pub min_expiration: i64, + pub max_expiration: i64, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct Statuses { + pub max_characters: i64, + pub max_media_attachments: i64, + pub characters_reserved_per_url: i64, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct Translation { + pub enabled: bool, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct Urls { + pub streaming: String, + pub about: String, + pub privacy_policy: String, + pub terms_of_service: String, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct Contact { + pub email: String, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct Field { + pub name: String, + pub value: String, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct Icon { + pub src: String, + pub size: String, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct Registrations { + pub enabled: bool, + pub approval_required: bool, + pub reason_required: bool, + pub message: Option, + pub min_age: i64, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct Thumbnail { + pub url: String, + } +} + +#[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/src/endpoints/admin/mod.rs b/ferri-server/src/endpoints/admin/mod.rs index 174b968..51e6ec7 100644 --- a/ferri-server/src/endpoints/admin/mod.rs +++ b/ferri-server/src/endpoints/admin/mod.rs @@ -1,14 +1,20 @@ -use rocket::{get, response::content::RawHtml}; +use rocket::{get, post, response::content::RawHtml}; use askama::Template; #[derive(Template)] #[template(path = "index.html")] struct IndexTemplate { + val: String +} +#[post("/clicked")] +pub async fn button_clicked() -> RawHtml { + let tmpl = IndexTemplate { val: "clicked".to_string() }; + RawHtml(tmpl.render().unwrap()) } #[get("/")] pub async fn index() -> RawHtml { - let tmpl = IndexTemplate { }; + let tmpl = IndexTemplate { val: "test".to_string() }; RawHtml(tmpl.render().unwrap()) } diff --git a/ferri-server/src/endpoints/api/preferences.rs b/ferri-server/src/endpoints/api/preferences.rs index afe4e56..a06db49 100644 --- a/ferri-server/src/endpoints/api/preferences.rs +++ b/ferri-server/src/endpoints/api/preferences.rs @@ -1,12 +1,26 @@ -use main::types::api; use rocket::{ get, - serde::json::Json, + serde::{Deserialize, Serialize, json::Json}, }; +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct Preferences { + #[serde(rename = "posting:default:visibility")] + pub posting_default_visibility: String, + #[serde(rename = "posting:default:sensitive")] + pub posting_default_sensitive: bool, + #[serde(rename = "posting:default:language")] + pub posting_default_language: Option, + #[serde(rename = "reading:expand:media")] + pub reading_expand_media: String, + #[serde(rename = "reading:expand:spoilers")] + pub reading_expand_spoilers: bool, +} + #[get("/preferences")] -pub async fn preferences() -> Json { - Json(api::Preferences { +pub async fn preferences() -> Json { + Json(Preferences { posting_default_visibility: "public".to_string(), posting_default_sensitive: false, posting_default_language: None, diff --git a/ferri-server/src/endpoints/api/search.rs b/ferri-server/src/endpoints/api/search.rs index 8750f2d..f332fc7 100644 --- a/ferri-server/src/endpoints/api/search.rs +++ b/ferri-server/src/endpoints/api/search.rs @@ -1,12 +1,12 @@ use rocket::{ get, serde::json::Json, FromFormField, State, }; -use main::{federation::http::HttpWrapper, types::{api, get}}; +use main::types::{api, get}; use rocket_db_pools::Connection; use serde::{Deserialize, Serialize}; use tracing::{info, error}; -use crate::{AuthenticatedUser, Db}; +use crate::{http_wrapper::HttpWrapper, AuthenticatedUser, Db}; #[derive(Serialize, Deserialize, FromFormField, Debug)] #[serde(rename_all = "lowercase")] diff --git a/ferri-server/src/endpoints/api/status.rs b/ferri-server/src/endpoints/api/status.rs index e884812..2ce38be 100644 --- a/ferri-server/src/endpoints/api/status.rs +++ b/ferri-server/src/endpoints/api/status.rs @@ -43,7 +43,7 @@ fn to_db_post(req: &CreateStatus, user: &AuthenticatedUser, config: &Config) -> uri: ObjectUri(config.post_url(&user.id.0, &post_id)), user: user.user.clone(), content: req.status.clone(), - created_at: main::now(), + created_at: main::ap::now(), boosted_post: None, attachments: vec![] } diff --git a/ferri-server/src/endpoints/api/timeline.rs b/ferri-server/src/endpoints/api/timeline.rs index f6c82d0..582b397 100644 --- a/ferri-server/src/endpoints/api/timeline.rs +++ b/ferri-server/src/endpoints/api/timeline.rs @@ -1,11 +1,48 @@ -use crate::{AuthenticatedUser, Db}; -use main::types::{api, get}; +use crate::{AuthenticatedUser, Db, endpoints::api::user::CredentialAcount}; +use main::types::{api, get, ObjectUuid}; use rocket::{ get, - serde::json::Json, + serde::{Deserialize, Serialize, json::Json}, }; use rocket_db_pools::Connection; +pub type TimelineAccount = CredentialAcount; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct TimelineStatusAttachment { + id: ObjectUuid, + #[serde(rename = "type")] + ty: String, + url: String, + description: String +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct TimelineStatus { + pub id: String, + pub created_at: String, + pub in_reply_to_id: Option, + pub in_reply_to_account_id: Option, + pub content: String, + pub visibility: String, + pub spoiler_text: String, + pub sensitive: bool, + pub uri: String, + pub url: String, + pub replies_count: i64, + pub reblogs_count: i64, + pub favourites_count: i64, + pub favourited: bool, + pub reblogged: bool, + pub muted: bool, + pub bookmarked: bool, + pub reblog: Option>, + pub media_attachments: Vec, + pub account: TimelineAccount, +} + #[get("/timelines/home")] pub async fn home( mut db: Connection, @@ -13,10 +50,7 @@ pub async fn home( ) -> Json> { let posts = get::home_timeline(user.actor_id, &mut **db) .await - .unwrap() - .into_iter() - .map(|p| p.into()) - .collect(); + .unwrap(); - Json(posts) + Json(posts.into_iter().map(Into::into).collect()) } diff --git a/ferri-server/src/endpoints/api/user.rs b/ferri-server/src/endpoints/api/user.rs index 63d824f..3f60f86 100644 --- a/ferri-server/src/endpoints/api/user.rs +++ b/ferri-server/src/endpoints/api/user.rs @@ -1,15 +1,14 @@ -use main::federation::outbox::OutboxRequest; -use main::federation::QueueMessage; -use main::types::{api, get, ObjectUuid}; +use main::ap; use rocket::response::status::NotFound; use rocket::{ State, get, post, serde::{Deserialize, Serialize, json::Json}, }; use rocket_db_pools::Connection; -use tracing::info; +use uuid::Uuid; -use crate::{AuthenticatedUser, Db, OutboundQueue}; +use crate::timeline::{TimelineAccount, TimelineStatus}; +use crate::{AuthenticatedUser, Db}; #[derive(Debug, Serialize, Deserialize)] #[serde(crate = "rocket::serde")] @@ -35,8 +34,7 @@ pub struct CredentialAcount { } #[get("/accounts/verify_credentials")] -pub async fn verify_credentials(user: AuthenticatedUser) -> Json { - info!("verifying creds for {:#?}", user); +pub async fn verify_credentials() -> Json { Json(CredentialAcount { id: "9b9d497b-2731-435f-a929-e609ca69dac9".to_string(), username: "amy".to_string(), @@ -62,49 +60,69 @@ pub async fn verify_credentials(user: AuthenticatedUser) -> Json/follow")] pub async fn new_follow( mut db: Connection, - outbound: &State, + helpers: &State, uuid: &str, user: AuthenticatedUser, ) -> Result<(), NotFound> { - let follower = user.user; - let followed = get::user_by_id(ObjectUuid(uuid.to_string()), &mut **db) + let http = &helpers.http; + + let follower = ap::User::from_actor_id(&user.actor_id.0, &mut **db).await; + + let followed = ap::User::from_id(uuid, &mut **db) .await - .unwrap(); + .map_err(|e| NotFound(e.to_string()))?; - let conn = db.into_inner(); - let conn = conn.detach(); + let outbox = ap::Outbox::for_user(follower.clone(), http); + + let activity = ap::Activity { + id: format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()), + ty: ap::ActivityType::Follow, + object: followed.actor_id().to_string(), + ..Default::default() + }; + + let req = ap::OutgoingActivity { + signed_by: format!("{}#main-key", follower.uri()), + req: activity, + to: followed.actor().clone(), + }; + + req.save(&mut **db).await; + outbox.post(req).await; - let msg = QueueMessage::Outbound(OutboxRequest::Follow { - follower, - followed, - conn - }); - - outbound.0.send(msg).await; - Ok(()) } -#[get("/accounts/relationships?")] -pub async fn relationships( - id: Vec, - user: AuthenticatedUser -) -> Result>, ()> { - info!("{} looking up relationships for {:#?}", user.username, id); - Ok(Json(vec![])) -} - #[get("/accounts/")] pub async fn account( mut db: Connection, uuid: &str, _user: AuthenticatedUser, -) -> Result, NotFound> { - let user = get::user_by_id(ObjectUuid(uuid.to_string()), &mut **db) +) -> Result, NotFound> { + let user = ap::User::from_id(uuid, &mut **db) .await .map_err(|e| NotFound(e.to_string()))?; - - Ok(Json(user.into())) + let user_uri = format!("https://ferri.amy.mov/users/{}", user.username()); + Ok(Json(CredentialAcount { + id: user.id().to_string(), + username: user.username().to_string(), + acct: user.username().to_string(), + display_name: user.display_name().to_string(), + 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(), + })) } #[get("/accounts//statuses?<_limit>")] @@ -113,17 +131,69 @@ pub async fn statuses( uuid: &str, _limit: Option, _user: AuthenticatedUser, -) -> Result>, NotFound> { - let user = get::user_by_id(ObjectUuid(uuid.to_string()), &mut **db) +) -> Result>, NotFound> { + let user = ap::User::from_id(uuid, &mut **db) .await .map_err(|e| NotFound(e.to_string()))?; - let posts = get::posts_for_user_id(user.id, &mut **db) - .await - .unwrap() - .into_iter() - .map(|p| p.into()) - .collect(); - - Ok(Json(posts)) + let uid = user.id(); + 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 + FROM post p + INNER JOIN user u on p.user_id = u.id + WHERE u.id = ?1 + ORDER BY p.created_at DESC + "#, uid) + .fetch_all(&mut **db) + .await + .unwrap(); + + let mut out = Vec::::new(); + for record in posts { + let user_uri = format!("https://ferri.amy.mov/users/{}", record.username); + out.push(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: None, + 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(), + }, + }); + } + + Ok(Json(out)) } diff --git a/ferri-server/src/endpoints/user.rs b/ferri-server/src/endpoints/user.rs index 6dcf5c3..61ff21d 100644 --- a/ferri-server/src/endpoints/user.rs +++ b/ferri-server/src/endpoints/user.rs @@ -50,14 +50,23 @@ pub async fn followers( mut db: Connection, uuid: &str, ) -> Result>, NotFound> { - let user = get::user_by_id(ObjectUuid(uuid.to_string()), &mut **db) + let target = main::ap::User::from_id(uuid, &mut **db) .await - .unwrap(); - - let followers = get::followers_for_user(user.id.clone(), &mut **db) - .await - .unwrap(); - + .map_err(|e| NotFound(e.to_string()))?; + + let actor_id = target.actor_id(); + + let followers = sqlx::query!( + r#" + SELECT follower_id FROM follow + WHERE followed_id = ? + "#, + actor_id + ) + .fetch_all(&mut **db) + .await + .unwrap(); + ap_ok(Json(OrderedCollection { context: as_context(), ty: "OrderedCollection".to_string(), @@ -65,8 +74,8 @@ pub async fn followers( id: format!("https://ferri.amy.mov/users/{}/followers", uuid), ordered_items: followers .into_iter() - .map(|f| f.follower.0) - .collect(), + .map(|f| f.follower_id) + .collect::>(), })) } @@ -75,23 +84,32 @@ pub async fn following( mut db: Connection, uuid: &str, ) -> Result>, NotFound> { - let user = get::user_by_id(ObjectUuid(uuid.to_string()), &mut **db) + let target = main::ap::User::from_id(uuid, &mut **db) .await - .unwrap(); - - let followers = get::following_for_user(user.id.clone(), &mut **db) - .await - .unwrap(); - + .map_err(|e| NotFound(e.to_string()))?; + + let actor_id = target.actor_id(); + + let following = sqlx::query!( + r#" + SELECT followed_id FROM follow + WHERE follower_id = ? + "#, + actor_id + ) + .fetch_all(&mut **db) + .await + .unwrap(); + ap_ok(Json(OrderedCollection { context: as_context(), ty: "OrderedCollection".to_string(), total_items: 1, id: format!("https://ferri.amy.mov/users/{}/following", uuid), - ordered_items: followers + ordered_items: following .into_iter() - .map(|f| f.followed.0) - .collect(), + .map(|f| f.followed_id) + .collect::>(), })) } diff --git a/ferri-server/src/endpoints/well_known.rs b/ferri-server/src/endpoints/well_known.rs index d73c99c..42d0373 100644 --- a/ferri-server/src/endpoints/well_known.rs +++ b/ferri-server/src/endpoints/well_known.rs @@ -1,6 +1,7 @@ use crate::Db; -use main::types::{api, get}; -use rocket::{get, serde::json::Json, State}; +use main::ap; +use main::types::api; +use rocket::{State, get, serde::json::Json}; use rocket_db_pools::Connection; use tracing::info; @@ -26,26 +27,24 @@ pub async fn webfinger( let acct = resource.strip_prefix("acct:").unwrap(); let (user, _) = acct.split_once("@").unwrap(); - let user = get::user_by_username(user, &mut **db) - .await - .unwrap(); + let user = ap::User::from_username(user, &mut **db).await; Json(api::WebfingerHit { subject: resource.to_string(), aliases: vec![ - config.user_url(&user.id.0), - config.user_web_url(&user.username), + config.user_url(user.id()), + config.user_web_url(user.username()), ], links: vec![ api::WebfingerLink { rel: "http://webfinger.net/rel/profile-page".to_string(), ty: Some("text/html".to_string()), - href: Some(config.user_web_url(&user.username)), + href: Some(config.user_web_url(user.username())), }, api::WebfingerLink { rel: "self".to_string(), ty: Some("application/activity+json".to_string()), - href: Some(config.user_url(&user.id.0)), + href: Some(config.user_url(user.id())), }, ], }) diff --git a/ferri-server/src/http_wrapper.rs b/ferri-server/src/http_wrapper.rs index e69de29..38c51be 100644 --- a/ferri-server/src/http_wrapper.rs +++ b/ferri-server/src/http_wrapper.rs @@ -0,0 +1,74 @@ +use crate::http::HttpClient; +use main::types::ap; +use std::fmt::Debug; +use thiserror::Error; +use tracing::{Level, error, event, info}; + +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 raw_body = raw_body.unwrap(); + info!("raw body {}", raw_body); + let decoded = serde_json::from_str::(&raw_body); + + if let Err(e) = decoded { + error!( + "could not parse {} for url {}: {:#?} {}", + ty, url, e, &raw_body + ); + 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 420d05a..5b8b59e 100644 --- a/ferri-server/src/lib.rs +++ b/ferri-server/src/lib.rs @@ -4,8 +4,10 @@ use endpoints::{ }; use tracing_subscriber::fmt; -use main::{federation::{self, http}, types::{db, get, ObjectUri, ObjectUuid}}; +use main::{federation, types::{db, get, ObjectUri, ObjectUuid}}; + +use main::ap::http; use main::config::Config; use rocket::{ Build, Request, Rocket, build, get, @@ -132,6 +134,7 @@ pub fn launch(cfg: Config) -> Rocket { "/admin", routes![ admin::index, + admin::button_clicked ] ) .mount( @@ -168,7 +171,6 @@ pub fn launch(cfg: Config) -> Rocket { api::user::new_follow, api::user::statuses, api::user::account, - api::user::relationships, api::apps::new_app, api::preferences::preferences, api::user::verify_credentials, diff --git a/ferri-server/templates/_layout.html b/ferri-server/templates/_layout.html deleted file mode 100644 index 9cc9117..0000000 --- a/ferri-server/templates/_layout.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - {% block title %}{% endblock %} - - {%~ block styles ~%} {% endblock ~%} - - - - - - {%~ block content %}{% endblock ~%} - - diff --git a/ferri-server/templates/index.html b/ferri-server/templates/index.html index 50db608..f912bcf 100644 --- a/ferri-server/templates/index.html +++ b/ferri-server/templates/index.html @@ -1,45 +1,19 @@ -{% extends "_layout.html" %} - -{%- block title -%} -Control panel -{%- endblock -%} - -{%- block styles -%} - -{%- endblock -%} - -{%- block content -%} -
-
-

Test

-
- -
-

Test

-
- -
-

Test

-
- -
-

Test

-
-
-{%- endblock -%} + + + + + + + + + Ferri Test + + + + + + + +