diff --git a/.gitignore b/.gitignore index 3c72226..a17b176 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .direnv/ -target \ No newline at end of file +target +*.pem +ferri.db* \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..366c092 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.checkOnSave": false +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 57ad006..8eb4999 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1255,7 +1255,15 @@ dependencies = [ name = "main" version = "0.1.0" dependencies = [ + "base64 0.22.1", + "chrono", + "rand 0.8.5", + "reqwest", + "rsa", "serde", + "serde_json", + "sqlx", + "url", ] [[package]] @@ -2144,16 +2152,12 @@ dependencies = [ name = "server" version = "0.1.0" dependencies = [ - "base64 0.22.1", "chrono", "main", - "rand 0.8.5", "reqwest", "rocket", "rocket_db_pools", - "rsa", "sqlx", - "url", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 03297e8..b0d9a9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,5 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-feature serde = "1.0.219" rocket = { version = "0.5.1", features = ["json"] } sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite", "macros" ], default-features = false } -uuid = { version = "1.16.0", features = ["v4"] } \ No newline at end of file +uuid = { version = "1.16.0", features = ["v4"] } +chrono = "0.4.40" \ No newline at end of file diff --git a/ferri-cli/src/main.rs b/ferri-cli/src/main.rs index bef5fdd..2e65d5d 100644 --- a/ferri-cli/src/main.rs +++ b/ferri-cli/src/main.rs @@ -9,33 +9,47 @@ use clap::Parser; #[derive(Parser)] #[command(version, about, long_about = None)] struct Cli { - #[arg(short, long)] - init: bool + #[arg(short, long)] + init: bool, } #[rocket::main] async fn main() { - let cli = Cli::parse(); - if cli.init { - // Seed DB - let pool = SqlitePool::connect(&env::var("DATABASE_URL").unwrap()).await.unwrap(); - let mut conn = pool.acquire().await.unwrap(); - - sqlx::query!(r#" + let cli = Cli::parse(); + if cli.init { + // Seed DB + let pool = SqlitePool::connect(&env::var("DATABASE_URL").unwrap()) + .await + .unwrap(); + let mut conn = pool.acquire().await.unwrap(); + + sqlx::query!( + r#" INSERT INTO actor (id, inbox, outbox) VALUES (?1, ?2, ?3) - "#, "https://ferri.amy.mov/users/amy", "https://ferri.amy.mov/users/amy/inbox", "https://ferri.amy.mov/users/amy/outbox") - .execute(&mut *conn) - .await.unwrap(); + "#, + "https://ferri.amy.mov/users/amy", + "https://ferri.amy.mov/users/amy/inbox", + "https://ferri.amy.mov/users/amy/outbox" + ) + .execute(&mut *conn) + .await + .unwrap(); - sqlx::query!(r#" - INSERT INTO user (id, actor_id, display_name) - VALUES (?1, ?2, ?3) - "#, "amy", "https://ferri.amy.mov/users/amy", "amy") - .execute(&mut *conn) - .await.unwrap(); - } else { - let _ = launch().launch().await; - } + sqlx::query!( + r#" + INSERT INTO user (id, username, actor_id, display_name) + VALUES (?1, ?2, ?3, ?4) + "#, + "9b9d497b-2731-435f-a929-e609ca69dac9", + "amy", + "https://ferri.amy.mov/users/amy", + "amy" + ) + .execute(&mut *conn) + .await + .unwrap(); + } else { + let _ = launch().launch().await; + } } - diff --git a/ferri-main/Cargo.toml b/ferri-main/Cargo.toml index 40ef291..282c128 100644 --- a/ferri-main/Cargo.toml +++ b/ferri-main/Cargo.toml @@ -5,3 +5,12 @@ edition = "2024" [dependencies] serde = { workspace = true } +serde_json = "1.0.140" +sqlx = { workspace = true } +chrono = { workspace = true } +reqwest = { workspace = true } + +base64 = "0.22.1" +rsa = { version = "0.9.8", features = ["sha2"] } +rand = "0.8" +url = "2.5.4" \ No newline at end of file diff --git a/ferri-main/src/ap/http.rs b/ferri-main/src/ap/http.rs new file mode 100644 index 0000000..1bc6860 --- /dev/null +++ b/ferri-main/src/ap/http.rs @@ -0,0 +1,192 @@ +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; + +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 { + dbg!(&self.inner); + 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 index b859990..a4f8436 100644 --- a/ferri-main/src/ap/mod.rs +++ b/ferri-main/src/ap/mod.rs @@ -1,27 +1,188 @@ -pub type ObjectId = String; +use chrono::{DateTime, Local}; +use serde::{Deserialize, Serialize}; +use sqlx::Sqlite; -pub enum ObjectType { - Person, -} - -pub struct Object { - id: ObjectId, - ty: ObjectType -} +pub mod http; +#[derive(Debug, Clone)] pub struct Actor { - obj: Object, - - inbox: Inbox, - outbox: Outbox, + id: String, + inbox: String, + outbox: String, } -pub struct Inbox {} +#[derive(Debug, Clone)] +pub struct User { + id: String, + username: String, + actor: Actor, + display_name: String, +} -pub struct Outbox {} +impl User { + pub fn id(&self) -> &str { + &self.id + } -pub struct Message {} + 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 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, + } + } +} + +#[derive(Debug, Clone)] +pub enum ActivityType { + Follow, +} + +impl ActivityType { + fn to_raw(self) -> String { + match self { + ActivityType::Follow => "Follow".to_string(), + } + } +} + +#[derive(Debug, Clone)] pub struct Activity { - + pub id: String, + pub ty: ActivityType, + pub object: String, + pub published: DateTime, +} + +pub type KeyId = String; + +#[derive(Debug, Clone)] +pub struct OutgoingActivity { + pub signed_by: KeyId, + pub req: Activity, + pub to: Actor, +} + +#[derive(Serialize, Deserialize, Debug)] +struct RawActivity { + #[serde(rename = "@context")] + #[serde(skip_deserializing)] + context: String, + + id: String, + #[serde(rename = "type")] + ty: String, + + actor: String, + object: String, + 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) { + dbg!(&activity); + let raw = RawActivity { + context: "https://www.w3.org/ns/activitystreams".to_string(), + id: activity.req.id, + ty: activity.req.ty.to_raw(), + actor: self.user.actor.id.clone(), + object: activity.req.object, + published: activity.req.published.to_rfc3339(), + }; + + dbg!(&raw); + + let follow_res = self + .transport + .post(activity.to.inbox) + .activity() + .json(&raw) + .sign(&activity.signed_by) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + dbg!(follow_res); + } + + pub fn for_user(user: User, transport: &'a OutboxTransport) -> Outbox<'a> { + Outbox { user, transport } + } } diff --git a/ferri-server/Cargo.toml b/ferri-server/Cargo.toml index 53b82eb..36574fa 100644 --- a/ferri-server/Cargo.toml +++ b/ferri-server/Cargo.toml @@ -9,9 +9,5 @@ rocket = { workspace = true } rocket_db_pools = { version = "0.2.0", features = ["sqlx_sqlite"] } reqwest = { workspace = true } sqlx = { workspace = true } -base64 = "0.22.1" -rsa = { version = "0.9.8", features = ["sha2"] } -rand = "0.8" -url = "2.5.4" -chrono = "0.4.40" -uuid = { workspace = true } \ No newline at end of file +uuid = { workspace = true } +chrono = { workspace = true } \ No newline at end of file diff --git a/ferri-server/src/ap.rs b/ferri-server/src/ap.rs deleted file mode 100644 index ed73a9e..0000000 --- a/ferri-server/src/ap.rs +++ /dev/null @@ -1,163 +0,0 @@ -use rocket::serde::{Serialize, Deserialize}; - -#[derive(Deserialize, Serialize, Debug)] -#[serde(crate = "rocket::serde")] -pub struct Link { - pub rel: String, - #[serde(rename = "type")] - pub ty: Option, - pub href: Option -} - -#[derive(Deserialize, Serialize, Debug)] -#[serde(crate = "rocket::serde")] -pub struct WebfingerResponse { - pub subject: String, - pub aliases: Vec, - pub links: Vec, -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -#[serde(crate = "rocket::serde")] -pub struct UserKey { - pub id: String, - pub owner: String, - #[serde(rename = "publicKeyPem")] - pub public_key: String -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -#[serde(crate = "rocket::serde")] -pub struct Person { - // FIXME: This is because Masto sends an array but we don't care - #[serde(rename = "@context")] - #[serde(skip_deserializing)] - pub context: String, - - pub id: String, - #[serde(rename = "type")] - pub ty: String, - pub following: String, - pub followers: String, - pub inbox: String, - pub outbox: String, - pub preferred_username: String, - pub name: String, - pub summary: String, - pub public_key: Option - // pub url: String, - // pub manually_approves_followers: bool, - // pub discoverable: bool, - // pub indexable: bool, - // pub published: String, - // pub memorial: bool, -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(crate = "rocket::serde")] -pub struct MinimalActivity { - pub id: String, - #[serde(rename = "type")] - pub ty: String, -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(crate = "rocket::serde")] -pub struct DeleteActivity { - pub id: String, - #[serde(rename = "type")] - pub ty: String, - - pub object: String, - pub actor: String -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(crate = "rocket::serde")] -pub struct CreateActivity { - pub id: String, - #[serde(rename = "type")] - pub ty: String, - - pub object: Post, - pub actor: String, - pub to: Vec, - pub cc: Vec, - #[serde(rename = "published")] - pub ts: String, - pub summary: String -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(crate = "rocket::serde")] -pub struct FollowActivity { - pub id: String, - #[serde(rename = "type")] - pub ty: String, - - pub object: String, - pub actor: String -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(crate = "rocket::serde")] -pub struct AcceptActivity { - #[serde(rename = "type")] - pub ty: String, - - pub object: String, - pub actor: String -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(crate = "rocket::serde")] -pub struct Post { - // FIXME: This is because Masto sends an array but we don't care - #[serde(rename = "@context")] - #[serde(skip_deserializing)] - pub context: String, - pub id: String, - #[serde(rename = "type")] - pub ty: String, - #[serde(rename = "published")] - pub ts: String, - pub content: String, - pub to: Vec, - pub cc: Vec -} - -#[derive(Serialize, Deserialize)] -#[serde(crate = "rocket::serde")] -pub struct Activity { - pub id: String, - #[serde(rename = "type")] - pub ty: String, - - pub summary: String, - pub actor: String, - pub object: String, - pub published: String, -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -#[serde(crate = "rocket::serde")] -pub struct Object { - pub id: String, - #[serde(rename = "type")] - pub ty: String, - pub object: String -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -#[serde(crate = "rocket::serde")] -pub struct OrderedCollection { - pub summary: String, - #[serde(rename = "type")] - pub ty: String, - pub total_items: u64, - pub ordered_items: Vec -} diff --git a/ferri-server/src/cors.rs b/ferri-server/src/cors.rs new file mode 100644 index 0000000..731d64b --- /dev/null +++ b/ferri-server/src/cors.rs @@ -0,0 +1,43 @@ +use rocket::{ + options, fairing::{Fairing, Info, Kind}, + Request, Response, http::{Method, Header} +}; + +pub struct CORS; + +#[rocket::async_trait] +impl Fairing for CORS { + fn info(&self) -> Info { + Info { + name: "Add CORS headers to responses", + kind: Kind::Response, + } + } + + async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) { + if request.method() == Method::Options { + response.set_status(rocket::http::Status::NoContent); + response.set_header(Header::new( + "Access-Control-Allow-Methods", + "POST, PATCH, GET, DELETE", + )); + response.set_header(Header::new( + "Access-Control-Allow-Headers", + "content-type, authorization", + )); + } + + response.set_header(Header::new("Access-Control-Allow-Origin", "*")); + response.set_header(Header::new( + "Access-Control-Allow-Methods", + "POST, GET, PATCH, OPTIONS", + )); + response.set_header(Header::new("Access-Control-Allow-Headers", "*")); + response.set_header(Header::new("Access-Control-Allow-Credentials", "true")); + } +} + +#[options("/<_path..>")] +pub fn options_req(_path: std::path::PathBuf) -> rocket::http::Status { + rocket::http::Status::Ok +} diff --git a/ferri-server/src/endpoints/api/apps.rs b/ferri-server/src/endpoints/api/apps.rs new file mode 100644 index 0000000..92cc1a1 --- /dev/null +++ b/ferri-server/src/endpoints/api/apps.rs @@ -0,0 +1,14 @@ +use rocket::{form::Form, post, serde::json::Json}; + +use crate::types::oauth::{App, CredentialApplication}; + +#[post("/apps", data = "")] +pub async fn new_app(app: Form) -> Json { + Json(CredentialApplication { + name: app.client_name.clone(), + scopes: app.scopes.clone(), + redirect_uris: app.redirect_uris.clone(), + client_id: format!("id-for-{}", app.client_name), + client_secret: format!("secret-for-{}", app.client_name), + }) +} \ No newline at end of file diff --git a/ferri-server/src/endpoints/api/instance.rs b/ferri-server/src/endpoints/api/instance.rs new file mode 100644 index 0000000..6fd927f --- /dev/null +++ b/ferri-server/src/endpoints/api/instance.rs @@ -0,0 +1,62 @@ +use rocket::{get, serde::json::Json}; + +use crate::types::instance::{Accounts, Configuration, Contact, Instance, MediaAttachments, Polls, Registrations, Statuses, Thumbnail, Translation, Urls}; + +#[get("/instance")] +pub async fn instance() -> Json { + Json(Instance { + domain: "ferri.amy.mov".to_string(), + title: "Ferri".to_string(), + version: "0.0.1".to_string(), + source_url: "https://forge.amy.mov/amy/ferri".to_string(), + description: "ferriverse".to_string(), + thumbnail: Thumbnail { + url: "".to_string(), + }, + icon: vec![], + languages: vec![], + configuration: Configuration { + urls: Urls { + streaming: "".to_string(), + about: "".to_string(), + privacy_policy: "".to_string(), + terms_of_service: "".to_string(), + }, + accounts: Accounts { + max_featured_tags: 10, + max_pinned_statuses: 10, + }, + statuses: Statuses { + max_characters: 1000, + max_media_attachments: 5, + characters_reserved_per_url: 10, + }, + media_attachments: MediaAttachments { + supported_mime_types: vec![], + description_limit: 10, + image_size_limit: 10, + image_matrix_limit: 10, + video_size_limit: 10, + video_frame_rate_limit: 10, + video_matrix_limit: 10, + }, + polls: Polls { + max_options: 10, + max_characters_per_option: 10, + min_expiration: 10, + max_expiration: 10, + }, + translation: Translation { enabled: false }, + }, + registrations: Registrations { + enabled: false, + approval_required: true, + reason_required: true, + message: None, + min_age: 10, + }, + contact: Contact { + email: "no".to_string(), + }, + }) +} \ No newline at end of file diff --git a/ferri-server/src/endpoints/api/mod.rs b/ferri-server/src/endpoints/api/mod.rs new file mode 100644 index 0000000..c79b726 --- /dev/null +++ b/ferri-server/src/endpoints/api/mod.rs @@ -0,0 +1,5 @@ +pub mod user; +pub mod apps; +pub mod instance; +pub mod status; +pub mod preferences; \ No newline at end of file diff --git a/ferri-server/src/endpoints/api/preferences.rs b/ferri-server/src/endpoints/api/preferences.rs new file mode 100644 index 0000000..393d95d --- /dev/null +++ b/ferri-server/src/endpoints/api/preferences.rs @@ -0,0 +1,28 @@ +use rocket::{get, serde::{json::Json, Deserialize, Serialize}}; + + +#[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(Preferences { + posting_default_visibility: "public".to_string(), + posting_default_sensitive: false, + posting_default_language: None, + reading_expand_media: "default".to_string(), + reading_expand_spoilers: false, + }) +} \ No newline at end of file diff --git a/ferri-server/src/endpoints/api/status.rs b/ferri-server/src/endpoints/api/status.rs new file mode 100644 index 0000000..55347ba --- /dev/null +++ b/ferri-server/src/endpoints/api/status.rs @@ -0,0 +1,40 @@ +use rocket::{ + FromForm, + form::Form, + post, + serde::{Deserialize, Serialize}, +}; +use rocket_db_pools::Connection; +use uuid::Uuid; +use main::ap; + +use crate::{AuthenticatedUser, Db}; +#[derive(Serialize, Deserialize, Debug, FromForm)] +#[serde(crate = "rocket::serde")] +pub struct Status { + status: String, +} + +#[post("/statuses", data = "")] +pub async fn new_status(mut db: Connection, status: Form, user: AuthenticatedUser) { + let user = ap::User::from_actor_id(&user.actor_id, &mut **db).await; + let post_id = Uuid::new_v4(); + let uri = format!("https://ferri.amy.mov/users/{}/posts/{}", user.username(), post_id); + let id = user.id(); + + let post = sqlx::query!( + r#" + INSERT INTO post (id, user_id, content) + VALUES (?1, ?2, ?3) + RETURNING * + "#, + uri, + id, + status.status + ) + .fetch_one(&mut **db) + .await + .unwrap(); + + dbg!(user, status, post); +} diff --git a/ferri-server/src/endpoints/api/user.rs b/ferri-server/src/endpoints/api/user.rs new file mode 100644 index 0000000..78103ad --- /dev/null +++ b/ferri-server/src/endpoints/api/user.rs @@ -0,0 +1,88 @@ +use chrono::Local; +use main::ap; +use rocket::{ + State, get, post, + serde::{Deserialize, Serialize, json::Json}, +}; +use rocket_db_pools::Connection; +use uuid::Uuid; + +use crate::{AuthenticatedUser, Db, http::HttpClient}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct CredentialAcount { + pub id: String, + 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: String, +} + +#[get("/accounts/verify_credentials")] +pub async fn verify_credentials() -> Json { + Json(CredentialAcount { + id: "https://ferri.amy.mov/users/amy".to_string(), + username: "amy".to_string(), + acct: "amy@ferri.amy.mov".to_string(), + display_name: "amy".to_string(), + locked: false, + bot: false, + created_at: "2025-04-10T22:12:09Z".to_string(), + attribution_domains: vec![], + note: "".to_string(), + url: "https://ferri.amy.mov/@amy".to_string(), + avatar: "https://i.sstatic.net/l60Hf.png".to_string(), + avatar_static: "https://i.sstatic.net/l60Hf.png".to_string(), + header: "https://i.sstatic.net/l60Hf.png".to_string(), + header_static: "https://i.sstatic.net/l60Hf.png".to_string(), + followers_count: 1, + following_count: 1, + statuses_count: 1, + last_status_at: "2025-04-10T22:14:34Z".to_string(), + }) +} + +#[post("/accounts//follow")] +pub async fn new_follow( + mut db: Connection, + http: &State, + account: &str, + user: AuthenticatedUser, +) { + let follower = ap::User::from_actor_id(&user.actor_id, &mut **db).await; + let followed = ap::User::from_username(account, &mut **db).await; + + let outbox = ap::Outbox::for_user(follower.clone(), http.inner()); + + let activity = ap::Activity { + id: format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()), + ty: ap::ActivityType::Follow, + object: followed.actor_id().to_string(), + published: Local::now(), + }; + + let req = ap::OutgoingActivity { + signed_by: format!( + "https://ferri.amy.mov/users/{}#main-key", + follower.username() + ), + req: activity, + to: followed.actor().clone(), + }; + + outbox.post(req).await; +} diff --git a/ferri-server/src/endpoints/custom.rs b/ferri-server/src/endpoints/custom.rs new file mode 100644 index 0000000..55dc93b --- /dev/null +++ b/ferri-server/src/endpoints/custom.rs @@ -0,0 +1,120 @@ +use main::ap::http::HttpClient; +use rocket::{get, response::status, State}; +use rocket_db_pools::Connection; + +use uuid::Uuid; + +use crate::{types::{self, activity, content, webfinger}, Db}; + +#[get("/finger/")] +pub async fn finger_account(mut db: Connection, account: &str) -> status::Accepted { + // user@host.com + let (name, host) = account.split_once("@").unwrap(); + let user = resolve_user(name, host).await; + + // Make actor + sqlx::query!( + r#" + INSERT INTO actor (id, inbox, outbox) + VALUES (?1, ?2, ?3) + ON CONFLICT(id) DO NOTHING + "#, + user.id, + user.inbox, + user.outbox + ) + .execute(&mut **db) + .await + .unwrap(); + + let uuid = Uuid::new_v4().to_string(); + let username = format!("{}@{}", user.name, host); + sqlx::query!( + r#" + INSERT INTO user (id, username, actor_id, display_name) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(id) DO NOTHING + "#, + uuid, + username, + user.id, + user.preferred_username + ) + .execute(&mut **db) + .await + .unwrap(); + + status::Accepted(format!("https://ferri.amy.mov/users/{}", username)) +} + +pub async fn resolve_user(acct: &str, host: &str) -> types::Person { + let client = reqwest::Client::new(); + let url = format!( + "https://{}/.well-known/webfinger?resource=acct:{}", + host, acct + ); + let wf = client + .get(url) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + let actor_link = wf + .links + .iter() + .find(|l| l.ty == Some("application/activity+json".to_string())) + .unwrap(); + + let href = actor_link.href.as_ref().unwrap(); + client + .get(href) + .header("Accept", "application/activity+json") + .send() + .await + .unwrap() + .json::() + .await + .unwrap() +} + +#[get("/test")] +pub async fn test(http: &State) -> &'static str { + let user = resolve_user("amy@fedi.amy.mov", "fedi.amy.mov").await; + + let post = activity::CreateActivity { + id: "https://ferri.amy.mov/activities/amy/20".to_string(), + ty: "Create".to_string(), + summary: "Amy create a note".to_string(), + actor: "https://ferri.amy.mov/users/amy".to_string(), + object: content::Post { + context: "https://www.w3.org/ns/activitystreams".to_string(), + id: "https://ferri.amy.mov/users/amy/posts/20".to_string(), + ty: "Note".to_string(), + content: "My first post".to_string(), + 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()], + }, + ts: "2025-04-10T10:48:11Z".to_string(), + to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()], + cc: vec![], + }; + + let key_id = "https://ferri.amy.mov/users/amy#main-key"; + let follow = http + .post(user.inbox) + .json(&post) + .sign(key_id) + .activity() + .send() + .await.unwrap() + .text() + .await.unwrap(); + + dbg!(follow); + + "Hello, world!" +} \ No newline at end of file diff --git a/ferri-server/src/endpoints/mod.rs b/ferri-server/src/endpoints/mod.rs new file mode 100644 index 0000000..048601e --- /dev/null +++ b/ferri-server/src/endpoints/mod.rs @@ -0,0 +1,12 @@ +use rocket::http::{ContentType, MediaType}; + +pub mod user; +pub mod oauth; + +pub mod api; +pub mod well_known; +pub mod custom; + +fn activity_type() -> ContentType { + ContentType(MediaType::new("application", "activity+json")) +} \ No newline at end of file diff --git a/ferri-server/src/endpoints/oauth.rs b/ferri-server/src/endpoints/oauth.rs new file mode 100644 index 0000000..a70c8fa --- /dev/null +++ b/ferri-server/src/endpoints/oauth.rs @@ -0,0 +1,35 @@ +use rocket::{get, post, response::Redirect, serde::{json::Json, Deserialize, Serialize}}; + +#[get("/oauth/authorize?&&&")] +pub async fn authorize( + client_id: &str, + scope: &str, + redirect_uri: &str, + response_type: &str, +) -> Redirect { + Redirect::temporary(format!( + "{}?code=code-for-{}&state=state-for-{}", + redirect_uri, client_id, client_id + )) +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub struct Token { + pub access_token: String, + pub token_type: String, + pub expires_in: i64, + pub scope: String, + pub id_token: String, +} + +#[post("/oauth/token")] +pub async fn new_token() -> Json { + Json(Token { + access_token: "access-token".to_string(), + token_type: "Bearer".to_string(), + expires_in: 3600, + scope: "read write follow push".to_string(), + id_token: "id-token".to_string(), + }) +} diff --git a/ferri-server/src/endpoints/user.rs b/ferri-server/src/endpoints/user.rs new file mode 100644 index 0000000..be52f61 --- /dev/null +++ b/ferri-server/src/endpoints/user.rs @@ -0,0 +1,213 @@ +use main::ap; +use rocket::{State, get, http::ContentType, post, serde::json::Json}; +use rocket_db_pools::Connection; + +use crate::{ + Db, + http::HttpClient, + types::{OrderedCollection, Person, UserKey, activity, content}, +}; + +use rocket::serde::json::serde_json; + +use super::activity_type; + +#[get("/users//inbox")] +pub async fn inbox(user: String) -> Json { + Json(OrderedCollection { + ty: "OrderedCollection".to_string(), + summary: format!("Inbox for {}", user), + total_items: 0, + ordered_items: vec![], + }) +} + +#[post("/users//inbox", data = "")] +pub async fn post_inbox( + mut db: Connection, + http: &State, + user: String, + body: String, +) { + let min = serde_json::from_str::(&body).unwrap(); + match min.ty.as_str() { + "Delete" => { + let activity = serde_json::from_str::(&body); + dbg!(activity.unwrap()); + } + "Follow" => { + let activity = serde_json::from_str::(&body).unwrap(); + dbg!(&activity); + + let user = http + .get(&activity.actor) + .activity() + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + sqlx::query!( + r#" + INSERT INTO actor (id, inbox, outbox) + VALUES ( ?1, ?2, ?3 ) + ON CONFLICT(id) DO NOTHING; + "#, + activity.actor, + user.inbox, + user.outbox + ) + .execute(&mut **db) + .await + .unwrap(); + + sqlx::query!( + r#" + INSERT INTO follow (id, follower_id, followed_id) + VALUES ( ?1, ?2, ?3 ) + ON CONFLICT(id) DO NOTHING; + "#, + activity.id, + activity.actor, + activity.object + ) + .execute(&mut **db) + .await + .unwrap(); + + let accept = activity::AcceptActivity { + ty: "Accept".to_string(), + actor: "https://ferri.amy.mov/users/amy".to_string(), + object: activity.id, + }; + + let key_id = "https://ferri.amy.mov/users/amy#main-key"; + let accept_res = http + .post(user.inbox) + .json(&accept) + .sign(key_id) + .activity() + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + dbg!(accept_res); + } + unknown => { + eprintln!("WARN: Unknown activity '{}' - {}", unknown, body); + } + } + + dbg!(min); + println!("Body in inbox: {}", body); +} + +#[get("/users//outbox")] +pub async fn outbox(user: String) -> Json { + dbg!(&user); + Json(OrderedCollection { + ty: "OrderedCollection".to_string(), + summary: format!("Outbox for {}", user), + total_items: 0, + ordered_items: vec![], + }) +} + +#[get("/users//followers")] +pub async fn followers(mut db: Connection, user: String) -> Json { + let target = ap::User::from_username(&user, &mut **db).await; + 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(); + + Json(OrderedCollection { + ty: "OrderedCollection".to_string(), + summary: format!("Followers for {}", user), + total_items: 1, + ordered_items: followers + .into_iter() + .map(|f| f.follower_id) + .collect::>(), + }) +} + +#[get("/users//following")] +pub async fn following(mut db: Connection, user: String) -> Json { + let target = ap::User::from_username(&user, &mut **db).await; + 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(); + + Json(OrderedCollection { + ty: "OrderedCollection".to_string(), + summary: format!("Following for {}", user), + total_items: 1, + ordered_items: following + .into_iter() + .map(|f| f.followed_id) + .collect::>(), + }) +} + +#[get("/users//posts/")] +pub async fn post(user: String, post: String) -> (ContentType, Json) { + ( + activity_type(), + Json(content::Post { + id: format!("https://ferri.amy.mov/users/{}/posts/{}", user, post), + context: "https://www.w3.org/ns/activitystreams".to_string(), + ty: "Note".to_string(), + content: "My first post".to_string(), + 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()], + }), + ) +} + +#[get("/users/")] +pub async fn user(user: String) -> (ContentType, Json) { + ( + activity_type(), + Json(Person { + context: "https://www.w3.org/ns/activitystreams".to_string(), + ty: "Person".to_string(), + id: format!("https://ferri.amy.mov/users/{}", user), + name: user.clone(), + preferred_username: user.clone(), + followers: format!("https://ferri.amy.mov/users/{}/followers", user), + following: format!("https://ferri.amy.mov/users/{}/following", user), + summary: format!("ferri {}", user), + inbox: format!("https://ferri.amy.mov/users/{}/inbox", user), + outbox: format!("https://ferri.amy.mov/users/{}/outbox", user), + public_key: Some(UserKey { + id: format!("https://ferri.amy.mov/users/{}#main-key", user), + owner: format!("https://ferri.amy.mov/users/{}", user), + 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 new file mode 100644 index 0000000..96c55e6 --- /dev/null +++ b/ferri-server/src/endpoints/well_known.rs @@ -0,0 +1,44 @@ +use main::ap; +use rocket::{get, serde::json::Json}; +use rocket_db_pools::Connection; + +use crate::{types::webfinger::{Link, WebfingerResponse}, Db}; + +#[get("/.well-known/host-meta")] +pub async fn host_meta() -> &'static str { + r#" + + + + + "# +} + +// 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 { + println!("Webfinger request for {}", resource); + let acct = resource.strip_prefix("acct:").unwrap(); + let (user, _) = acct.split_once("@").unwrap(); + let user = ap::User::from_username(user, &mut **db).await; + + Json(WebfingerResponse { + subject: resource.to_string(), + aliases: vec![ + format!("https://ferri.amy.mov/users/{}", user.username()), + format!("https://ferri.amy.mov/@{}", user.username()), + ], + links: vec![ + Link { + rel: "http://webfinger.net/rel/profile-page".to_string(), + ty: Some("text/html".to_string()), + href: Some(format!("https://ferri.amy.mov/@{}", user.username())), + }, + Link { + rel: "self".to_string(), + ty: Some("application/activity+json".to_string()), + href: Some(format!("https://ferri.amy.mov/users/{}", user.username())), + }, + ], + }) +} \ No newline at end of file diff --git a/ferri-server/src/lib.rs b/ferri-server/src/lib.rs index e08aa59..898da07 100644 --- a/ferri-server/src/lib.rs +++ b/ferri-server/src/lib.rs @@ -1,227 +1,23 @@ -use rocket::serde::json::Json; -use rocket::serde::json::serde_json; -use rocket::{Rocket, Build, build, get, post, routes, http::{MediaType, ContentType}}; -use reqwest; +use main::ap::{http}; +use rocket::{ + build, get, http::ContentType, request::{FromRequest, Outcome}, routes, serde::{ + json::Json, Deserialize, Serialize + }, Build, Request, Rocket +}; -use uuid::Uuid; -use base64::prelude::*; +mod cors; +mod types; +mod endpoints; -use rocket::serde::Serialize; -use rocket::serde::Deserialize; +use endpoints::{api::{self, user::CredentialAcount}, oauth, well_known, custom, user}; -use rocket::Request; -use rocket::request::Outcome; -use rocket::request::FromRequest; -use rocket::FromForm; - -use rsa::{RsaPrivateKey, pkcs8::DecodePrivateKey}; -use rsa::pkcs1v15::SigningKey; -use rsa::signature::{RandomizedSigner, SignatureEncoding}; -use rsa::sha2::{Digest, Sha256}; - -use rocket::form::Form; - -use url::Url; -use chrono::Utc; - -mod ap; - -use rocket_db_pools::{Database, Connection}; -use rocket_db_pools::sqlx::{self, Row}; +use rocket_db_pools::sqlx; +use rocket_db_pools::Database; #[derive(Database)] #[database("sqlite_ferri")] -struct Db(sqlx::SqlitePool); - -#[get("/users//inbox")] -async fn inbox(user: String) -> Json { - dbg!(&user); - Json(ap::OrderedCollection { - ty: "OrderedCollection".to_string(), - summary: format!("Inbox for {}", user), - total_items: 0, - ordered_items: vec![] - }) -} - -#[post("/users//inbox", data="")] -async fn post_inbox(mut db: Connection, user: String, body: String) { - let client = reqwest::Client::new(); - - let min = serde_json::from_str::(&body).unwrap(); - match min.ty.as_str() { - "Delete" => { - let activity = serde_json::from_str::(&body); - dbg!(activity); - } - "Follow" => { - let activity = serde_json::from_str::(&body).unwrap(); - dbg!(&activity); - let user = client.get(&activity.actor) - .header("Accept", "application/activity+json") - .send() - .await.unwrap() - .json::() - .await.unwrap(); - - sqlx::query!(r#" - INSERT INTO actor (id, inbox, outbox) - VALUES ( ?1, ?2, ?3 ) - ON CONFLICT(id) DO NOTHING; - "#, activity.actor, user.inbox, user.outbox) - .execute(&mut **db) - .await.unwrap(); - - sqlx::query!(r#" - INSERT INTO follow (id, follower_id, followed_id) - VALUES ( ?1, ?2, ?3 ) - ON CONFLICT(id) DO NOTHING; - "#, activity.id, activity.actor, activity.object) - .execute(&mut **db) - .await.unwrap(); - - let accept = ap::AcceptActivity { - ty: "Accept".to_string(), - actor: "https://ferri.amy.mov/users/amy".to_string(), - object: activity.id - }; - - let key_id = "https://ferri.amy.mov/users/amy#main-key".to_string(); - let document = serde_json::to_string(&accept).unwrap(); - let signature = sign_post_request(key_id, user.inbox.clone(), document); - dbg!(&signature); - - let follow_res = client.post(user.inbox) - .header("Content-Type", "application/activity+json") - .header("Date", signature.date) - .header("Digest", signature.digest) - .header("Signature", signature.signature) - .json(&accept) - .send() - .await.unwrap() - .text() - .await.unwrap(); - - dbg!(follow_res); - } - unknown => { - eprintln!("WARN: Unknown activity '{}' - {}", unknown, body); - } - } - - dbg!(min); - println!("body: {}", body); -} - -#[get("/users//outbox")] -async fn outbox(user: String) -> Json { - dbg!(&user); - Json(ap::OrderedCollection { - ty: "OrderedCollection".to_string(), - summary: format!("Outbox for {}", user), - total_items: 0, - ordered_items: vec![] - }) -} - -#[get("/users//followers")] -async fn followers(mut db: Connection, user: String) -> Json { - let target = FerriUser::by_name(&user, &mut **db).await; - - let followers = sqlx::query!( r#" - SELECT follower_id FROM follow - WHERE followed_id = ? - "#, target.actor_id) - .fetch_all(&mut **db) - .await.unwrap(); - - Json(ap::OrderedCollection { - ty: "OrderedCollection".to_string(), - summary: format!("Followers for {}", user), - total_items: 1, - ordered_items: followers.into_iter().map(|f| f.follower_id).collect::>() - }) -} - -#[derive(Debug)] -struct FerriUser { - id: String, - actor_id: String, - display_name: String -} - -impl FerriUser { - async fn by_name<'a>( - name: &'a str, - conn: impl sqlx::Executor<'a, Database = sqlx::Sqlite> - ) -> FerriUser { - sqlx::query_as!(FerriUser, r#" - SELECT * FROM user - WHERE display_name = ? - "#, name) - .fetch_one(conn) - .await.unwrap() - } -} - - -#[get("/users//following")] -async fn following(mut db: Connection, user: String) -> Json { - let target = FerriUser::by_name(&user, &mut **db).await; - - let following = sqlx::query!( r#" - SELECT followed_id FROM follow - WHERE follower_id = ? - "#, target.actor_id) - .fetch_all(&mut **db) - .await.unwrap(); - - Json(ap::OrderedCollection { - ty: "OrderedCollection".to_string(), - summary: format!("Following for {}", user), - total_items: 1, - ordered_items: following.into_iter().map(|f| f.followed_id).collect::>() - }) -} - -fn activity_type() -> ContentType { - ContentType(MediaType::new("application", "activity+json")) -} - -#[get("/users//posts/")] -async fn get_post(user: String, post: String) -> (ContentType, Json) { - (activity_type(), Json(ap::Post { - id: format!("https://ferri.amy.mov/users/{}/posts/{}", user, post), - context: "https://www.w3.org/ns/activitystreams".to_string(), - ty: "Note".to_string(), - content: "My first post".to_string(), - 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()], - })) -} - -#[get("/users/")] -async fn user(user: String) -> (ContentType, Json) { - (activity_type(), Json(ap::Person { - context: "https://www.w3.org/ns/activitystreams".to_string(), - ty: "Person".to_string(), - id: format!("https://ferri.amy.mov/users/{}", user), - name: user.clone(), - preferred_username: user.clone(), - followers: format!("https://ferri.amy.mov/users/{}/followers", user), - following: format!("https://ferri.amy.mov/users/{}/following", user), - summary: format!("ferri {}", user), - inbox: format!("https://ferri.amy.mov/users/{}/inbox", user), - outbox: format!("https://ferri.amy.mov/users/{}/outbox", user), - public_key: Some(ap::UserKey { - id: format!("https://ferri.amy.mov/users/{}#main-key", user), - owner: format!("https://ferri.amy.mov/users/{}", user), - public_key: include_str!("../../public.pem").to_string(), - }) - })) -} +pub struct Db(sqlx::SqlitePool); #[get("/")] async fn user_profile() -> (ContentType, &'static str) { @@ -229,219 +25,21 @@ async fn user_profile() -> (ContentType, &'static str) { } #[get("/activities/")] -async fn activity(activity: String) { +async fn activity_endpoint(activity: String) { dbg!(activity); } -// https://mastodon.social/.well-known/webfinger?resource=acct:gargron@mastodon.social -#[get("/.well-known/webfinger?")] -async fn webfinger(mut db: Connection, resource: &str) -> Json { - println!("Webfinger request for {}", resource); - let acct = resource.strip_prefix("acct:").unwrap(); - let (user, _) = acct.split_once("@").unwrap(); - - let user = FerriUser::by_name(user, &mut **db).await; - dbg!(&user); - - Json(ap::WebfingerResponse { - subject: resource.to_string(), - aliases: vec![ - format!("https://ferri.amy.mov/users/{}", user.id), - format!("https://ferri.amy.mov/@{}", user.id) - ], - links: vec![ - ap::Link { - rel: "http://webfinger.net/rel/profile-page".to_string(), - ty: Some("text/html".to_string()), - href: Some(format!("https://ferri.amy.mov/@{}", user.id)) - }, - ap::Link { - rel: "self".to_string(), - ty: Some("application/activity+json".to_string()), - href: Some(format!("https://ferri.amy.mov/users/{}", user.id)) - } - ] - }) -} - -async fn resolve_user(acct: &str, host: &str) -> ap::Person { - let client = reqwest::Client::new(); - let url = format!("https://{}/.well-known/webfinger?resource=acct:{}", host, acct); - let wf = client.get(url) - .send() - .await.unwrap() - .json::() - .await.unwrap(); - - let actor_link = wf.links - .iter() - .find(|l| l.ty == Some("application/activity+json".to_string())) - .unwrap(); - - let href = actor_link.href.as_ref().unwrap(); - client.get(href) - .header("Accept", "application/activity+json") - .send() - .await.unwrap() - .json::() - .await.unwrap() -} - -#[derive(Debug)] -struct PostSignature { - date: String, - digest: String, - signature: String -} - -#[derive(Debug)] -struct GetSignature { - date: String, - signature: String -} - -fn sign_get_request(key_id: String, raw_url: String) -> GetSignature { - let url = Url::parse(&raw_url).unwrap(); - - 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(); - dbg!(&date); - - 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: date, - signature: header - } -} - -fn sign_post_request(key_id: String, raw_url: String, body: String) -> PostSignature { - let url = Url::parse(&raw_url).unwrap(); - - 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(); - dbg!(&date); - - 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: date, - digest: digest, - signature: header - } -} - -#[get("/test")] -async fn index() -> &'static str { - let client = reqwest::Client::new(); - let user = resolve_user("amy@fedi.amy.mov", "fedi.amy.mov").await; - dbg!(&user); - - let post = ap::CreateActivity { - id: "https://ferri.amy.mov/activities/amy/20".to_string(), - ty: "Create".to_string(), - summary: "Amy create a note".to_string(), - actor: "https://ferri.amy.mov/users/amy".to_string(), - object: ap::Post { - context: "https://www.w3.org/ns/activitystreams".to_string(), - id: "https://ferri.amy.mov/users/amy/posts/20".to_string(), - ty: "Note".to_string(), - content: "My first post".to_string(), - 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()], - }, - ts: "2025-04-10T10:48:11Z".to_string(), - to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()], - cc: vec![], - }; - - let key_id = "https://ferri.amy.mov/users/amy#main-key".to_string(); - let document = serde_json::to_string(&post).unwrap(); - let signature = sign_post_request(key_id, user.inbox.clone(), document); - dbg!(&signature); - - let follow_res = client.post(user.inbox) - .header("Content-Type", "application/activity+json") - .header("Accept", "application/activity+json") - .header("Date", signature.date) - .header("Digest", signature.digest) - .header("Signature", signature.signature) - .json(&post) - .send() - .await.unwrap() - .text() - .await.unwrap(); - - println!("{}", follow_res); - - "Hello, world!" -} - -#[derive(Serialize, Deserialize, Debug, FromForm)] -#[serde(crate = "rocket::serde")] -struct Status { - status: String, -} - #[derive(Debug)] struct AuthenticatedUser { - id: String + username: String, + actor_id: String } #[derive(Debug)] enum LoginError { InvalidData, UsernameDoesNotExist, - WrongPassword + WrongPassword, } #[rocket::async_trait] @@ -450,42 +48,119 @@ impl<'a> FromRequest<'a> for AuthenticatedUser { async fn from_request(request: &'a Request<'_>) -> Outcome { let token = request.headers().get_one("Authorization").unwrap(); Outcome::Success(AuthenticatedUser { - id: token.to_string() + username: token.to_string(), + actor_id: format!("https://ferri.amy.mov/users/{}", token) }) } } -#[post("/statuses", data="")] -async fn new_status(mut db: Connection, status: Form, user: AuthenticatedUser) { - let user = FerriUser::by_name(&user.id, &mut **db).await; - let post_id = Uuid::new_v4(); - let uri = format!("https://ferri.amy.mov/users/amy/posts/{}", post_id); - - let post = sqlx::query!(r#" - INSERT INTO post (id, user_id, content) - VALUES (?1, ?2, ?3) - RETURNING * - "#, uri, user.id, status.status).fetch_one(&mut **db).await; - - dbg!(user, status, post); +pub type TimelineAccount = CredentialAcount; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +struct TimelineStatus { + id: String, + created_at: String, + in_reply_to_id: Option, + in_reply_to_account_id: Option, + content: String, + visibility: String, + spoiler_text: String, + sensitive: bool, + uri: String, + url: String, + replies_count: i64, + reblogs_count: i64, + favourites_count: i64, + favourited: bool, + reblogged: bool, + muted: bool, + bookmarked: bool, + media_attachments: Vec<()>, + account: TimelineAccount, +} + +#[get("/timelines/home?")] +async fn home_timeline(limit: i64) -> Json> { + Json(vec![TimelineStatus { + id: "1".to_string(), + created_at: "2025-04-10T22:12:09Z".to_string(), + in_reply_to_id: None, + in_reply_to_account_id: None, + content: "My first post".to_string(), + visibility: "public".to_string(), + spoiler_text: "".to_string(), + sensitive: false, + uri: "https://ferri.amy.mov/users/amy/posts/1".to_string(), + url: "https://ferri.amy.mov/users/amy/posts/1".to_string(), + replies_count: 0, + reblogs_count: 0, + favourites_count: 0, + favourited: false, + reblogged: false, + muted: false, + bookmarked: false, + media_attachments: vec![], + account: CredentialAcount { + id: "https://ferri.amy.mov/users/amy".to_string(), + username: "amy".to_string(), + acct: "amy@ferri.amy.mov".to_string(), + display_name: "amy".to_string(), + locked: false, + bot: false, + created_at: "2025-04-10T22:12:09Z".to_string(), + attribution_domains: vec![], + note: "".to_string(), + url: "https://ferri.amy.mov/@amy".to_string(), + avatar: "https://i.sstatic.net/l60Hf.png".to_string(), + avatar_static: "https://i.sstatic.net/l60Hf.png".to_string(), + header: "https://i.sstatic.net/l60Hf.png".to_string(), + header_static: "https://i.sstatic.net/l60Hf.png".to_string(), + followers_count: 1, + following_count: 1, + statuses_count: 1, + last_status_at: "2025-04-10T22:14:34Z".to_string(), + }, + }]) } pub fn launch() -> Rocket { - build().attach(Db::init()) - .mount("/", routes![ - index, - inbox, - post_inbox, - outbox, - user, - user_profile, - get_post, - followers, - following, - activity, - webfinger - ]) - .mount("/api/v1", routes![ - new_status - ]) + let http_client = http::HttpClient::new(); + build() + .manage(http_client) + .attach(Db::init()) + .attach(cors::CORS) + .mount( + "/", + routes![ + custom::test, + user::inbox, + user::post_inbox, + user::outbox, + user::user, + user::followers, + user::following, + user::post, + oauth::authorize, + oauth::new_token, + cors::options_req, + activity_endpoint, + well_known::webfinger, + well_known::host_meta, + user_profile, + ], + ) + .mount("/api/v2", routes![api::instance::instance]) + .mount( + "/api/v1", + routes![ + api::status::new_status, + api::user::new_follow, + api::apps::new_app, + api::preferences::preferences, + api::user::verify_credentials, + custom::finger_account, + home_timeline + ], + ) } diff --git a/ferri-server/src/types/activity.rs b/ferri-server/src/types/activity.rs new file mode 100644 index 0000000..73f13b6 --- /dev/null +++ b/ferri-server/src/types/activity.rs @@ -0,0 +1,72 @@ +use rocket::serde::{Deserialize, Serialize}; + +use crate::types::content::Post; + +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct Activity { + pub id: String, + #[serde(rename = "type")] + pub ty: String, + + pub summary: String, + pub actor: String, + pub object: String, + pub published: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub struct MinimalActivity { + pub id: String, + #[serde(rename = "type")] + pub ty: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub struct DeleteActivity { + pub id: String, + #[serde(rename = "type")] + pub ty: String, + + pub object: String, + pub actor: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub struct CreateActivity { + pub id: String, + #[serde(rename = "type")] + pub ty: String, + + pub object: Post, + pub actor: String, + pub to: Vec, + pub cc: Vec, + #[serde(rename = "published")] + pub ts: String, + pub summary: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub struct FollowActivity { + pub id: String, + #[serde(rename = "type")] + pub ty: String, + + pub object: String, + pub actor: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub struct AcceptActivity { + #[serde(rename = "type")] + pub ty: String, + + pub object: String, + pub actor: String, +} \ No newline at end of file diff --git a/ferri-server/src/types/content.rs b/ferri-server/src/types/content.rs new file mode 100644 index 0000000..b6b8263 --- /dev/null +++ b/ferri-server/src/types/content.rs @@ -0,0 +1,18 @@ +use rocket::serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub struct Post { + // FIXME: This is because Masto sends an array but we don't care + #[serde(rename = "@context")] + #[serde(skip_deserializing)] + pub context: String, + pub id: String, + #[serde(rename = "type")] + pub ty: String, + #[serde(rename = "published")] + pub ts: String, + pub content: String, + pub to: Vec, + pub cc: Vec, +} \ No newline at end of file diff --git a/ferri-server/src/types/instance.rs b/ferri-server/src/types/instance.rs new file mode 100644 index 0000000..bfc7c1d --- /dev/null +++ b/ferri-server/src/types/instance.rs @@ -0,0 +1,115 @@ +use rocket::serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +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)] +#[serde(crate = "rocket::serde")] +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)] +#[serde(crate = "rocket::serde")] +pub struct Accounts { + pub max_featured_tags: i64, + pub max_pinned_statuses: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +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)] +#[serde(crate = "rocket::serde")] +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)] +#[serde(crate = "rocket::serde")] +pub struct Statuses { + pub max_characters: i64, + pub max_media_attachments: i64, + pub characters_reserved_per_url: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct Translation { + pub enabled: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct Urls { + pub streaming: String, + pub about: String, + pub privacy_policy: String, + pub terms_of_service: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct Contact { + pub email: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct Field { + pub name: String, + pub value: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct Icon { + pub src: String, + pub size: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +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)] +#[serde(crate = "rocket::serde")] +pub struct Thumbnail { + pub url: String, +} diff --git a/ferri-server/src/types/mod.rs b/ferri-server/src/types/mod.rs new file mode 100644 index 0000000..ce3e00f --- /dev/null +++ b/ferri-server/src/types/mod.rs @@ -0,0 +1,61 @@ +pub mod activity; +pub mod content; +pub mod instance; +pub mod oauth; +pub mod webfinger; + +use rocket::serde::{Deserialize, Serialize}; + + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +#[serde(crate = "rocket::serde")] +pub struct UserKey { + pub id: String, + pub owner: String, + #[serde(rename = "publicKeyPem")] + pub public_key: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +#[serde(crate = "rocket::serde")] +pub struct Person { + // FIXME: This is because Masto sends an array but we don't care + #[serde(rename = "@context")] + #[serde(skip_deserializing)] + pub context: String, + + pub id: String, + #[serde(rename = "type")] + pub ty: String, + pub following: String, + pub followers: String, + pub inbox: String, + pub outbox: String, + pub preferred_username: String, + pub name: String, + pub summary: String, + pub public_key: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +#[serde(crate = "rocket::serde")] +pub struct Object { + pub id: String, + #[serde(rename = "type")] + pub ty: String, + pub object: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +#[serde(crate = "rocket::serde")] +pub struct OrderedCollection { + pub summary: String, + #[serde(rename = "type")] + pub ty: String, + pub total_items: u64, + pub ordered_items: Vec, +} diff --git a/ferri-server/src/types/oauth.rs b/ferri-server/src/types/oauth.rs new file mode 100644 index 0000000..0517f75 --- /dev/null +++ b/ferri-server/src/types/oauth.rs @@ -0,0 +1,19 @@ +use rocket::{serde::{Deserialize, Serialize}, FromForm}; + +#[derive(Serialize, Deserialize, Debug, FromForm)] +#[serde(crate = "rocket::serde")] +pub struct App { + pub client_name: String, + pub redirect_uris: Vec, + pub scopes: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "rocket::serde")] +pub struct CredentialApplication { + pub name: String, + pub scopes: String, + pub redirect_uris: Vec, + pub client_id: String, + pub client_secret: String, +} \ No newline at end of file diff --git a/ferri-server/src/types/webfinger.rs b/ferri-server/src/types/webfinger.rs new file mode 100644 index 0000000..33d8edd --- /dev/null +++ b/ferri-server/src/types/webfinger.rs @@ -0,0 +1,18 @@ +use rocket::serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Debug)] +#[serde(crate = "rocket::serde")] +pub struct Link { + pub rel: String, + #[serde(rename = "type")] + pub ty: Option, + pub href: Option, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(crate = "rocket::serde")] +pub struct WebfingerResponse { + pub subject: String, + pub aliases: Vec, + pub links: Vec, +} \ No newline at end of file diff --git a/flake.nix b/flake.nix index f47a678..15cf319 100644 --- a/flake.nix +++ b/flake.nix @@ -30,7 +30,7 @@ packages = with pkgs; [ sqlx-cli (rust-bin.stable.latest.default.override { - extensions = [ "rust-src" ]; + extensions = [ "rust-src" "rust-analyzer" ]; targets = [ ]; }) ]; diff --git a/migrations/20250410112126_add_actor.sql b/migrations/20250410112126_add_actor.sql index d066005..1608f7a 100644 --- a/migrations/20250410112126_add_actor.sql +++ b/migrations/20250410112126_add_actor.sql @@ -1,7 +1,7 @@ CREATE TABLE IF NOT EXISTS actor ( -- URI - id TEXT PRIMARY KEY NOT NULL, + id TEXT PRIMARY KEY NOT NULL, inbox TEXT NOT NULL, outbox TEXT NOT NULL ); diff --git a/migrations/20250410121119_add_user.sql b/migrations/20250410121119_add_user.sql index 6cab68e..ada5bab 100644 --- a/migrations/20250410121119_add_user.sql +++ b/migrations/20250410121119_add_user.sql @@ -1,7 +1,8 @@ CREATE TABLE IF NOT EXISTS user ( - -- Username - id TEXT PRIMARY KEY NOT NULL, + -- UUID + id TEXT PRIMARY KEY NOT NULL, + username TEXT NOT NULL, actor_id TEXT NOT NULL, display_name TEXT NOT NULL,