diff --git a/ferri-main/src/ap/mod.rs b/ferri-main/src/ap/mod.rs index 09d20cb..b6a5914 100644 --- a/ferri-main/src/ap/mod.rs +++ b/ferri-main/src/ap/mod.rs @@ -47,6 +47,34 @@ impl User { &self.actor } + pub async fn from_id( + uuid: &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 u.id = ?1 + "#, + uuid + ) + .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_username( username: &str, conn: impl sqlx::Executor<'_, Database = Sqlite>, diff --git a/ferri-server/src/endpoints/api/status.rs b/ferri-server/src/endpoints/api/status.rs index 507d82d..ea9bfa9 100644 --- a/ferri-server/src/endpoints/api/status.rs +++ b/ferri-server/src/endpoints/api/status.rs @@ -4,12 +4,15 @@ use rocket::{ FromForm, State, form::Form, post, - serde::{Deserialize, Serialize}, + serde::{Deserialize, Serialize, json::Json}, }; use rocket_db_pools::Connection; use uuid::Uuid; +use crate::timeline::TimelineStatus; use crate::{AuthenticatedUser, Db, types::content}; +use crate::api::user::CredentialAcount; + #[derive(Serialize, Deserialize, Debug, FromForm)] #[serde(crate = "rocket::serde")] pub struct Status { @@ -26,7 +29,7 @@ pub async fn new_status( let user = ap::User::from_actor_id(&user.actor_id, &mut **db).await; let outbox = ap::Outbox::for_user(user.clone(), http); - let post_id = Uuid::new_v4(); + let post_id = Uuid::new_v4().to_string(); let uri = format!( "https://ferri.amy.mov/users/{}/posts/{}", @@ -38,10 +41,11 @@ pub async fn new_status( let post = sqlx::query!( r#" - INSERT INTO post (id, user_id, content, created_at) - VALUES (?1, ?2, ?3, ?4) + INSERT INTO post (id, uri, user_id, content, created_at) + VALUES (?1, ?2, ?3, ?4, ?5) RETURNING * "#, + post_id, uri, id, status.status, @@ -102,3 +106,137 @@ pub async fn new_status( outbox.post(req).await; } } + +#[post("/statuses", data = "", rank = 2)] +pub async fn new_status_json( + mut db: Connection, + http: &State, + status: Json, + user: AuthenticatedUser, +) -> Json { + dbg!(&user); + let user = ap::User::from_id(&user.username, &mut **db).await; + let outbox = ap::Outbox::for_user(user.clone(), http); + + let post_id = Uuid::new_v4().to_string(); + + let uri = format!( + "https://ferri.amy.mov/users/{}/posts/{}", + user.id(), + post_id + ); + let id = user.id(); + let now = Local::now().to_rfc3339(); + + let post = sqlx::query!( + r#" + INSERT INTO post (id, uri, user_id, content, created_at) + VALUES (?1, ?2, ?3, ?4, ?5) + RETURNING * + "#, + post_id, + uri, + id, + status.status, + now + ) + .fetch_one(&mut **db) + .await + .unwrap(); + + let actors = sqlx::query!("SELECT * FROM actor") + .fetch_all(&mut **db) + .await + .unwrap(); + + for record in actors { + // Don't send to ourselves + if &record.id == user.actor_id() { + continue + } + + let create_id = format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()); + + let activity = ap::Activity { + id: create_id, + ty: ap::ActivityType::Create, + object: content::Post { + context: "https://www.w3.org/ns/activitystreams".to_string(), + id: uri.clone(), + content: status.status.clone(), + ty: "Note".to_string(), + ts: Local::now().to_rfc3339(), + to: vec![format!( + "https://ferri.amy.mov/users/{}/followers", + user.username() + )], + cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()], + }, + to: vec![format!( + "https://ferri.amy.mov/users/{}/followers", + user.username() + )], + cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()], + ..Default::default() + }; + + let actor = ap::Actor::from_raw( + record.id.clone(), + record.inbox.clone(), + record.outbox.clone(), + ); + let req = ap::OutgoingActivity { + req: activity, + signed_by: format!("https://ferri.amy.mov/users/{}#main-key", user.username()), + to: actor, + }; + + req.save(&mut **db).await; + outbox.post(req).await; + } + + let user_uri = format!( + "https://ferri.amy.mov/users/{}", + user.id(), + ); + Json(TimelineStatus { + id: post.id.clone(), + created_at: post.created_at.clone(), + in_reply_to_id: None, + in_reply_to_account_id: None, + content: post.content.clone(), + visibility: "public".to_string(), + spoiler_text: "".to_string(), + sensitive: false, + uri: post.uri.clone(), + url: post.uri.clone(), + replies_count: 0, + reblogs_count: 0, + favourites_count: 0, + favourited: false, + reblogged: false, + muted: false, + bookmarked: false, + media_attachments: vec![], + account: 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(), + } + }) +} diff --git a/ferri-server/src/endpoints/api/timeline.rs b/ferri-server/src/endpoints/api/timeline.rs index c120dc1..3d9e50c 100644 --- a/ferri-server/src/endpoints/api/timeline.rs +++ b/ferri-server/src/endpoints/api/timeline.rs @@ -10,32 +10,33 @@ pub type TimelineAccount = CredentialAcount; #[derive(Debug, Serialize, Deserialize)] #[serde(crate = "rocket::serde")] pub 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, + 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 media_attachments: Vec<()>, + pub account: TimelineAccount, } #[get("/timelines/home?")] pub async fn home(mut db: Connection, limit: i64) -> Json> { let posts = sqlx::query!( r#" - SELECT p.id as "post_id", u.id as "user_id", p.content, u.username, u.display_name, u.actor_id FROM post p + 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 "# ) @@ -45,17 +46,18 @@ pub async fn home(mut db: Connection, limit: i64) -> Json::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: "2025-04-10T22:12:09Z".to_string(), + 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_id.clone(), - url: record.post_id.clone(), + uri: record.post_uri.clone(), + url: record.post_uri.clone(), replies_count: 0, reblogs_count: 0, favourites_count: 0, @@ -65,7 +67,7 @@ pub async fn home(mut db: Connection, limit: i64) -> Json, limit: i64) -> Json, limit: i64) -> Json Json { 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(), + 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, @@ -55,15 +56,15 @@ pub async fn verify_credentials() -> Json { }) } -#[post("/accounts//follow")] +#[post("/accounts//follow")] pub async fn new_follow( mut db: Connection, http: &State, - account: &str, + uuid: &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 followed = ap::User::from_id(uuid, &mut **db).await; let outbox = ap::Outbox::for_user(follower.clone(), http.inner()); @@ -86,3 +87,98 @@ pub async fn new_follow( req.save(&mut **db).await; outbox.post(req).await; } + +#[get("/accounts/")] +pub async fn account(mut db: Connection, uuid: &str, user: AuthenticatedUser) -> Json { + let user = ap::User::from_id(uuid, &mut **db).await; + let user_uri = format!("https://ferri.amy.mov/users/{}", user.username()); + 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?")] +pub async fn statuses( + mut db: Connection, + uuid: &str, + limit: Option, + user: AuthenticatedUser, +) -> Json> { + let user = ap::User::from_id(uuid, &mut **db).await; + + 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 + "#, 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, + 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(), + }, + }); + } + + Json(out) +} diff --git a/ferri-server/src/endpoints/inbox.rs b/ferri-server/src/endpoints/inbox.rs index 543d652..e87b83e 100644 --- a/ferri-server/src/endpoints/inbox.rs +++ b/ferri-server/src/endpoints/inbox.rs @@ -111,6 +111,13 @@ async fn handle_follow_activity(followed_account: String, activity: activity::Fo outbox.post(req).await; } +async fn handle_like_activity(activity: activity::LikeActivity, mut db: Connection) { + let target_post = sqlx::query!("SELECT * FROM post WHERE uri = ?1", activity.object) + .fetch_one(&mut **db) + .await.unwrap(); + dbg!(&target_post); +} + async fn handle_create_activity(activity: activity::CreateActivity,http: &HttpClient, mut db: Connection) { assert!(&activity.object.ty == "Note"); let user = http @@ -128,21 +135,17 @@ async fn handle_create_activity(activity: activity::CreateActivity,http: &HttpCl let user = ap::User::from_actor_id(&activity.actor, &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 user_id = user.id(); let now = Local::now().to_rfc3339(); let content = activity.object.content.clone(); + let post_id = Uuid::new_v4().to_string(); + let uri = activity.id; + sqlx::query!(r#" - INSERT INTO post (id, user_id, content, created_at) - VALUES (?1, ?2, ?3, ?4) - "#, uri, id, content, now) + INSERT INTO post (id, uri, user_id, content, created_at) + VALUES (?1, ?2, ?3, ?4, ?5) + "#, post_id, uri, user_id, content, now) .execute(&mut **db) .await.unwrap(); } @@ -163,6 +166,10 @@ pub async fn inbox(db: Connection, http: &State, user: String, b let activity = serde_json::from_str::(&body).unwrap(); handle_create_activity(activity, http.inner(), db).await; }, + "Like" => { + let activity = serde_json::from_str::(&body).unwrap(); + handle_like_activity(activity, db).await; + }, unknown => { eprintln!("WARN: Unknown activity '{}' - {}", unknown, body); } diff --git a/ferri-server/src/endpoints/oauth.rs b/ferri-server/src/endpoints/oauth.rs index a70c8fa..7da8bf8 100644 --- a/ferri-server/src/endpoints/oauth.rs +++ b/ferri-server/src/endpoints/oauth.rs @@ -26,7 +26,7 @@ pub struct Token { #[post("/oauth/token")] pub async fn new_token() -> Json { Json(Token { - access_token: "access-token".to_string(), + access_token: "9b9d497b-2731-435f-a929-e609ca69dac9".to_string(), token_type: "Bearer".to_string(), expires_in: 3600, scope: "read write follow push".to_string(), diff --git a/ferri-server/src/endpoints/user.rs b/ferri-server/src/endpoints/user.rs index 36e3ccc..6cc0e05 100644 --- a/ferri-server/src/endpoints/user.rs +++ b/ferri-server/src/endpoints/user.rs @@ -31,9 +31,9 @@ pub async fn outbox(user: String) -> Json { }) } -#[get("/users//followers")] -pub async fn followers(mut db: Connection, user: String) -> Json { - let target = ap::User::from_username(&user, &mut **db).await; +#[get("/users//followers")] +pub async fn followers(mut db: Connection, uuid: &str) -> Json { + let target = ap::User::from_id(uuid, &mut **db).await; let actor_id = target.actor_id(); let followers = sqlx::query!( @@ -49,7 +49,7 @@ pub async fn followers(mut db: Connection, user: String) -> Json, user: String) -> Json/following")] -pub async fn following(mut db: Connection, user: String) -> Json { - let target = ap::User::from_username(&user, &mut **db).await; +#[get("/users//following")] +pub async fn following(mut db: Connection, uuid: &str) -> Json { + let target = ap::User::from_id(uuid, &mut **db).await; let actor_id = target.actor_id(); let following = sqlx::query!( @@ -76,7 +76,7 @@ pub async fn following(mut db: Connection, user: String) -> Json, user: String) -> Json/posts/")] -pub async fn post(user: String, post: String) -> (ContentType, Json) { +#[get("/users//posts/")] +pub async fn post(mut db: Connection, uuid: &str, post: String) -> (ContentType, Json) { + let post = sqlx::query!(r#" + SELECT * FROM post WHERE id = ?1 + "#, post) + .fetch_one(&mut **db) + .await.unwrap(); + ( activity_type(), Json(content::Post { - id: format!("https://ferri.amy.mov/users/{}/posts/{}", user, post), context: "https://www.w3.org/ns/activitystreams".to_string(), + id: format!("https://ferri.amy.mov/users/{}/posts/{}", uuid, post.id), ty: "Note".to_string(), - content: "My first post".to_string(), - ts: "2025-04-10T10:48:11Z".to_string(), + content: post.content, + ts: post.created_at, 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) { +#[get("/users/")] +pub async fn user(mut db: Connection, uuid: &str) -> (ContentType, Json) { + let user = ap::User::from_id(uuid, &mut **db).await; ( 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), + id: user.id().to_string(), + name: user.username().to_string(), + preferred_username: user.display_name().to_string(), + followers: format!("https://ferri.amy.mov/users/{}/followers", uuid), + following: format!("https://ferri.amy.mov/users/{}/following", uuid), + summary: format!("ferri {}", user.username()), + inbox: format!("https://ferri.amy.mov/users/{}/inbox", uuid), + outbox: format!("https://ferri.amy.mov/users/{}/outbox", uuid), public_key: Some(UserKey { - id: format!("https://ferri.amy.mov/users/{}#main-key", user), - owner: format!("https://ferri.amy.mov/users/{}", user), + id: format!("https://ferri.amy.mov/users/{}#main-key", uuid), + owner: format!("https://ferri.amy.mov/users/{}", uuid), public_key: include_str!("../../../public.pem").to_string(), }), }), diff --git a/ferri-server/src/endpoints/well_known.rs b/ferri-server/src/endpoints/well_known.rs index 96c55e6..012ace6 100644 --- a/ferri-server/src/endpoints/well_known.rs +++ b/ferri-server/src/endpoints/well_known.rs @@ -25,7 +25,7 @@ pub async fn webfinger(mut db: Connection, resource: &str) -> Json, resource: &str) -> Json FromRequest<'a> for AuthenticatedUser { type Error = LoginError; async fn from_request(request: &'a Request<'_>) -> Outcome { let token = request.headers().get_one("Authorization").unwrap(); + let token = token.strip_prefix("Bearer").map(|s| s.trim()).unwrap_or(token); Outcome::Success(AuthenticatedUser { username: token.to_string(), actor_id: format!("https://ferri.amy.mov/users/{}", token) @@ -80,7 +81,10 @@ pub fn launch() -> Rocket { "/api/v1", routes![ api::status::new_status, + api::status::new_status_json, api::user::new_follow, + api::user::statuses, + api::user::account, api::apps::new_app, api::preferences::preferences, api::user::verify_credentials, diff --git a/ferri-server/src/types/activity.rs b/ferri-server/src/types/activity.rs index 165925d..76b9acc 100644 --- a/ferri-server/src/types/activity.rs +++ b/ferri-server/src/types/activity.rs @@ -10,9 +10,12 @@ pub struct MinimalActivity { pub ty: String, } +pub type DeleteActivity = BasicActivity; +pub type LikeActivity = BasicActivity; + #[derive(Serialize, Deserialize, Debug)] #[serde(crate = "rocket::serde")] -pub struct DeleteActivity { +pub struct BasicActivity { pub id: String, #[serde(rename = "type")] pub ty: String, @@ -56,4 +59,4 @@ pub struct AcceptActivity { pub object: String, pub actor: String, -} \ No newline at end of file +} diff --git a/migrations/20250410112126_add_actor.sql b/migrations/20250410112126_add_actor.sql index 1608f7a..f1586be 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/20250410182845_add_post.sql b/migrations/20250410182845_add_post.sql index f2e47bd..96088c8 100644 --- a/migrations/20250410182845_add_post.sql +++ b/migrations/20250410182845_add_post.sql @@ -1,7 +1,7 @@ CREATE TABLE IF NOT EXISTS post ( - -- Uri id TEXT PRIMARY KEY NOT NULL, + uri TEXT NOT NULL, user_id TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL, diff --git a/migrations/20250411125138_add_activity.sql b/migrations/20250411125138_add_activity.sql index 2ccf07d..560de45 100644 --- a/migrations/20250411125138_add_activity.sql +++ b/migrations/20250411125138_add_activity.sql @@ -2,8 +2,8 @@ CREATE TABLE IF NOT EXISTS activity ( -- UUID id TEXT PRIMARY KEY NOT NULL, - ty TEXT NOT NULL, - actor_id TEXT NOT NULL, + ty TEXT NOT NULL, + actor_id TEXT NOT NULL, FOREIGN KEY(actor_id) REFERENCES actor(id) );