diff --git a/ferri-cli/src/main.rs b/ferri-cli/src/main.rs index 36969c8..b67ad9e 100644 --- a/ferri-cli/src/main.rs +++ b/ferri-cli/src/main.rs @@ -55,9 +55,9 @@ async fn main() { r#" INSERT INTO user ( id, acct, url, remote, username, - actor_id, display_name, created_at + actor_id, display_name, created_at, icon_url ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) "#, "9b9d497b-2731-435f-a929-e609ca69dac9", "amy", @@ -66,7 +66,8 @@ async fn main() { "amy", "https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9", "amy", - ts + ts, + "https://ferri.amy.mov/assets/pfp.png" ) .execute(&mut *conn) .await diff --git a/ferri-main/src/types/convert.rs b/ferri-main/src/types/convert.rs index 5453cd4..c473954 100644 --- a/ferri-main/src/types/convert.rs +++ b/ferri-main/src/types/convert.rs @@ -44,10 +44,10 @@ impl From for api::Account { note: "".to_string(), url: val.url, - 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(), + avatar: val.icon_url.clone(), + avatar_static: val.icon_url.clone(), + header: val.icon_url.clone(), + header_static: val.icon_url, followers_count: 0, following_count: 0, @@ -79,6 +79,42 @@ impl From for ap::Person { owner: format!("https://ferri.amy.mov/users/{}", val.id.0), public_key: include_str!("../../../public.pem").to_string(), }), + icon: None } } } + +impl From for api::Status { + fn from(value: db::Post) -> api::Status { + api::Status { + id: value.id, + created_at: value.created_at.to_rfc3339(), + in_reply_to_id: None, + in_reply_to_account_id: None, + sensitive: false, + spoiler_text: String::new(), + visibility: "Public".to_string(), + language: "en-GB".to_string(), + uri: value.uri.clone(), + url: value.uri.0.to_string(), + replies_count: 0, + reblogs_count: 0, + favourites_count: 0, + favourited: false, + reblogged: false, + muted: false, + bookmarked: false, + content: value.content, + reblog: None, + application: None, + account: value.user.into(), + media_attachments: vec![], + mentions: vec![], + tags: vec![], + emojis: vec![], + card: None, + poll: None + } + } +} + diff --git a/ferri-main/src/types/get.rs b/ferri-main/src/types/get.rs index 09ff5fe..18627f4 100644 --- a/ferri-main/src/types/get.rs +++ b/ferri-main/src/types/get.rs @@ -40,7 +40,8 @@ pub async fn user_by_id(id: ObjectUuid, conn: &mut SqliteConnection) -> Result Result Result { + info!("fetching user by actor_uri '{:?}' from the database", uri); + + 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.actor_id = ?1 + "#, + uri.0 + ) + .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), + 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 + }) +} + +pub async fn posts_for_user_id( + id: ObjectUuid, + conn: &mut SqliteConnection +) -> Result, DbError> { + let mut out = vec![]; + 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 as "post_created", + p.boosted_post_id, a.inbox, a.outbox, u.created_at as "user_created", + u.acct, u.remote, u.url as "user_url", u.icon_url + FROM post p + INNER JOIN user u on p.user_id = u.id + INNER JOIN actor a ON u.actor_id = a.id + WHERE p.user_id = ? + "#, id.0) + .fetch_all(&mut *conn) + .await + .unwrap(); + + for record in posts { + let user_created = parse_ts(record.user_created) + .expect("no db corruption"); + + out.push(db::Post { + id: ObjectUuid(record.post_id), + uri: ObjectUri(record.post_uri), + user: db::User { + id: ObjectUuid(record.user_id), + 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.user_url, + icon_url: record.icon_url, + posts: db::UserPosts { + last_post_at: None + } + }, + content: record.content, + created_at: parse_ts(record.post_created).unwrap(), + boosted_post: None + }) + } + + Ok(out) +} diff --git a/ferri-main/src/types/make.rs b/ferri-main/src/types/make.rs index 36c5696..5383d98 100644 --- a/ferri-main/src/types/make.rs +++ b/ferri-main/src/types/make.rs @@ -5,8 +5,9 @@ pub async fn new_user(user: db::User, conn: &mut SqliteConnection) -> Result Result, + pub icon_url: String, pub posts: UserPosts, } + + #[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 mod ap { @@ -210,6 +221,25 @@ pub mod ap { 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 { @@ -227,6 +257,8 @@ pub mod ap { pub name: String, pub public_key: Option, + + pub icon: Option } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -271,6 +303,37 @@ pub mod api { pub links: Vec, } + #[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, diff --git a/ferri-server/src/endpoints/api/mod.rs b/ferri-server/src/endpoints/api/mod.rs index 02fe1da..cccade5 100644 --- a/ferri-server/src/endpoints/api/mod.rs +++ b/ferri-server/src/endpoints/api/mod.rs @@ -4,3 +4,4 @@ pub mod preferences; pub mod status; pub mod timeline; pub mod user; +pub mod search; diff --git a/ferri-server/src/endpoints/api/search.rs b/ferri-server/src/endpoints/api/search.rs new file mode 100644 index 0000000..c009c0f --- /dev/null +++ b/ferri-server/src/endpoints/api/search.rs @@ -0,0 +1,88 @@ +use rocket::{ + get, serde::json::Json, FromFormField, State, +}; +use main::types::{api, get}; +use rocket_db_pools::Connection; +use serde::{Deserialize, Serialize}; +use tracing::{info, error}; + +use crate::{http_wrapper::HttpWrapper, AuthenticatedUser, Db}; + +#[derive(Serialize, Deserialize, FromFormField, Debug)] +#[serde(rename_all = "lowercase")] +pub enum SearchType { + Accounts, + Hashtags, + Statuses, + All +} + +impl Default for SearchType { + fn default() -> Self { + Self::All + } +} + +#[derive(Serialize, Deserialize)] +pub struct SearchResults { + statuses: Vec, + accounts: Vec, + hashtags: Vec<()> +} + +#[get("/search?&")] +pub async fn search( + q: &str, + r#type: SearchType, + helpers: &State, + mut db: Connection, + user: AuthenticatedUser +) -> Json { + let ty = r#type; + info!("search for {} (ty: {:?})", q, ty); + + let key_id = "https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9#main-key"; + let http = HttpWrapper::new(&helpers.http, key_id); + + let mut accounts = vec![]; + let mut statuses = vec![]; + + match ty { + SearchType::Accounts => { + let person = { + let res = http.get_person(q).await; + if let Err(e) = res { + error!("could not load user {}: {}", q, e.to_string()); + None + } else { + Some(res.unwrap()) + } + }; + + let user = get::user_by_actor_uri(person.unwrap().obj.id, &mut **db) + .await + .unwrap(); + + accounts.push(user.into()) + }, + SearchType::Statuses => { + if q == "me" { + let st = get::posts_for_user_id(user.id, &mut **db) + .await + .unwrap(); + + for status in st.into_iter() { + statuses.push(status.into()); + } + } + }, + SearchType::Hashtags => todo!(), + SearchType::All => todo!(), + } + + Json(SearchResults { + statuses, + accounts, + hashtags: vec![], + }) +} diff --git a/ferri-server/src/endpoints/api/status.rs b/ferri-server/src/endpoints/api/status.rs index 8e7694d..90b766c 100644 --- a/ferri-server/src/endpoints/api/status.rs +++ b/ferri-server/src/endpoints/api/status.rs @@ -43,7 +43,7 @@ async fn create_status( http: &HttpClient, status: &Status, ) -> TimelineStatus { - let user = ap::User::from_id(&user.id, &mut **db).await.unwrap(); + let user = ap::User::from_id(&user.id.0, &mut **db).await.unwrap(); let outbox = ap::Outbox::for_user(user.clone(), http); let post_id = ap::new_id(); diff --git a/ferri-server/src/endpoints/api/timeline.rs b/ferri-server/src/endpoints/api/timeline.rs index f9b868c..57be00f 100644 --- a/ferri-server/src/endpoints/api/timeline.rs +++ b/ferri-server/src/endpoints/api/timeline.rs @@ -35,11 +35,8 @@ pub struct TimelineStatus { #[get("/timelines/home")] pub async fn home( mut db: Connection, - helpers: &State, _user: AuthenticatedUser, ) -> Json> { - let config = &helpers.config; - #[derive(sqlx::FromRow, Debug)] struct Post { is_boost_source: bool, @@ -51,6 +48,8 @@ pub async fn home( boosted_post_id: Option, display_name: String, username: String, + icon_url: String, + user_url: String } // FIXME: query! can't cope with this. returns a type error @@ -75,7 +74,7 @@ pub async fn home( ) SELECT is_boost_source, p.id as "post_id", u.id as "user_id", p.content, p.uri as "post_uri", u.username, u.display_name, - u.actor_id, p.created_at, p.boosted_post_id + u.actor_id, p.created_at, p.boosted_post_id, u.icon_url, u.url as "user_url" FROM get_home_timeline_with_boosts JOIN post p ON p.id = get_home_timeline_with_boosts.id JOIN user u ON u.id = p.user_id; @@ -90,7 +89,6 @@ pub async fn home( for record in posts.iter() { let mut boost: Option> = None; if let Some(ref boosted_id) = record.boosted_post_id { - let user_uri = config.user_url(&record.user_id); let record = posts.iter().find(|p| &p.post_id == boosted_id).unwrap(); boost = Some(Box::new(TimelineStatus { @@ -123,11 +121,11 @@ pub async fn home( 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(), + url: record.user_url.clone(), + avatar: record.icon_url.clone(), + avatar_static: record.icon_url.clone(), + header: record.icon_url.clone(), + header_static: record.icon_url.clone(), followers_count: 1, following_count: 1, statuses_count: 1, @@ -137,7 +135,6 @@ pub async fn home( } if !record.is_boost_source { - let user_uri = config.user_web_url(&record.username); out.push(TimelineStatus { id: record.post_id.clone(), created_at: record.created_at.clone(), @@ -168,11 +165,11 @@ pub async fn home( 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(), + url: record.user_url.clone(), + avatar: record.icon_url.clone(), + avatar_static: record.icon_url.clone(), + header: record.icon_url.clone(), + header_static: record.icon_url.clone(), followers_count: 1, following_count: 1, statuses_count: 1, diff --git a/ferri-server/src/endpoints/api/user.rs b/ferri-server/src/endpoints/api/user.rs index 2c174a4..c07b749 100644 --- a/ferri-server/src/endpoints/api/user.rs +++ b/ferri-server/src/endpoints/api/user.rs @@ -66,7 +66,7 @@ pub async fn new_follow( ) -> Result<(), NotFound> { let http = &helpers.http; - let follower = ap::User::from_actor_id(&user.actor_id, &mut **db).await; + let follower = ap::User::from_actor_id(&user.actor_id.0, &mut **db).await; let followed = ap::User::from_id(uuid, &mut **db) .await diff --git a/ferri-server/src/endpoints/inbox.rs b/ferri-server/src/endpoints/inbox.rs index 2dabede..9750187 100644 --- a/ferri-server/src/endpoints/inbox.rs +++ b/ferri-server/src/endpoints/inbox.rs @@ -57,6 +57,9 @@ async fn create_user( }; let url = format!("https://ferri.amy.mov/{}", acct); + let icon_url = user.icon.as_ref().map(|ic| ic.url.clone()).unwrap_or( + "https://ferri.amy.mov/assets/pfp.png".to_string() + ); let uuid = Uuid::new_v4().to_string(); // FIXME: Pull from user @@ -65,10 +68,10 @@ async fn create_user( r#" INSERT INTO user ( id, acct, url, remote, username, - actor_id, display_name, created_at + actor_id, display_name, created_at, icon_url ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT(actor_id) DO NOTHING; + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(actor_id) DO NOTHING; "#, uuid, acct, @@ -77,7 +80,8 @@ async fn create_user( user.preferred_username, actor, user.name, - ts + ts, + icon_url ) .execute(conn) .await @@ -170,6 +174,9 @@ async fn resolve_actor<'a>( remote: remote_info.is_remote, url: remote_info.web_url, created_at: main::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 }, }; diff --git a/ferri-server/src/endpoints/oauth.rs b/ferri-server/src/endpoints/oauth.rs index 49f728c..853c04f 100644 --- a/ferri-server/src/endpoints/oauth.rs +++ b/ferri-server/src/endpoints/oauth.rs @@ -6,7 +6,6 @@ use rocket::{ FromForm, form::Form, get, post, - response::Redirect, response::status::BadRequest, response::content::RawHtml, serde::{Deserialize, Serialize, json::Json}, @@ -81,8 +80,7 @@ pub async fn authorize( client_id: &str, scope: &str, redirect_uri: &str, - response_type: &str, - mut db: Connection, + response_type: &str ) -> Result, BadRequest> { if response_type != "code" { error!("unknown response type {}", response_type); diff --git a/ferri-server/src/http_wrapper.rs b/ferri-server/src/http_wrapper.rs index 050ea49..38c51be 100644 --- a/ferri-server/src/http_wrapper.rs +++ b/ferri-server/src/http_wrapper.rs @@ -2,7 +2,7 @@ use crate::http::HttpClient; use main::types::ap; use std::fmt::Debug; use thiserror::Error; -use tracing::{Level, error, event}; +use tracing::{Level, error, event, info}; pub struct HttpWrapper<'a> { client: &'a HttpClient, @@ -54,6 +54,7 @@ impl<'a> HttpWrapper<'a> { } let raw_body = raw_body.unwrap(); + info!("raw body {}", raw_body); let decoded = serde_json::from_str::(&raw_body); if let Err(e) = decoded { diff --git a/ferri-server/src/lib.rs b/ferri-server/src/lib.rs index adf3a21..04b1e4d 100644 --- a/ferri-server/src/lib.rs +++ b/ferri-server/src/lib.rs @@ -5,7 +5,7 @@ use endpoints::{ use tracing_subscriber::fmt; -use main::ap; +use main::{ap, types::{ObjectUri, ObjectUuid}}; use main::ap::http; use main::config::Config; @@ -36,9 +36,9 @@ async fn activity_endpoint(_activity: String) {} #[derive(Debug)] pub struct AuthenticatedUser { pub username: String, - pub id: String, + pub id: ObjectUuid, pub token: String, - pub actor_id: String, + pub actor_id: ObjectUri, } #[derive(Debug)] @@ -72,9 +72,9 @@ impl<'a> FromRequest<'a> for AuthenticatedUser { if let Ok(auth) = auth { return Outcome::Success(AuthenticatedUser { token: auth.token, - id: auth.id, + id: ObjectUuid(auth.id), username: auth.display_name, - actor_id: auth.actor_id, + actor_id: ObjectUri(auth.actor_id), }); } } @@ -150,7 +150,10 @@ pub fn launch(cfg: Config) -> Rocket { user_profile, ], ) - .mount("/api/v2", routes![api::instance::instance]) + .mount("/api/v2", routes![ + api::instance::instance, + api::search::search, + ]) .mount( "/api/v1", routes![ diff --git a/migrations/20250410121119_add_user.sql b/migrations/20250410121119_add_user.sql index 2dbadf1..10a280c 100644 --- a/migrations/20250410121119_add_user.sql +++ b/migrations/20250410121119_add_user.sql @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS user username TEXT NOT NULL, actor_id TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, + icon_url TEXT NOT NULL, FOREIGN KEY(actor_id) REFERENCES actor(id) );