From 8cf7834cfedd7ad2efd7ea56445446d3e7851779 Mon Sep 17 00:00:00 2001 From: nullishamy Date: Sun, 4 May 2025 12:58:34 +0100 Subject: [PATCH] feat: attachments; other cleanup --- ferri-main/src/federation/http.rs | 4 + ferri-main/src/federation/inbox.rs | 166 +++++++- ferri-main/src/types/get.rs | 24 +- ferri-main/src/types/make.rs | 33 +- ferri-main/src/types/mod.rs | 34 +- ferri-server/src/endpoints/api/timeline.rs | 37 +- ferri-server/src/endpoints/inbox.rs | 2 +- ferri-server/src/endpoints/old_code.rs | 388 +++++++++++++++++++ ferri-server/src/endpoints/user.rs | 1 + migrations/20250504102047_add_attachment.sql | 13 + 10 files changed, 687 insertions(+), 15 deletions(-) create mode 100644 ferri-server/src/endpoints/old_code.rs create mode 100644 migrations/20250504102047_add_attachment.sql diff --git a/ferri-main/src/federation/http.rs b/ferri-main/src/federation/http.rs index 4d686e3..4d30b32 100644 --- a/ferri-main/src/federation/http.rs +++ b/ferri-main/src/federation/http.rs @@ -75,6 +75,10 @@ impl<'a> HttpWrapper<'a> { self.get("Person", url).await } + pub async fn get_note(&self, url: &str) -> Result { + self.get("Note", url).await + } + pub async fn post_activity( &self, inbox: &str, diff --git a/ferri-main/src/federation/inbox.rs b/ferri-main/src/federation/inbox.rs index 7c441a3..c41c096 100644 --- a/ferri-main/src/federation/inbox.rs +++ b/ferri-main/src/federation/inbox.rs @@ -6,7 +6,7 @@ use super::outbox::OutboxRequest; use super::QueueMessage; use chrono::DateTime; -use tracing::warn; +use tracing::{warn, error, Level, event}; #[derive(Debug)] pub enum InboxRequest { @@ -19,7 +19,7 @@ pub enum InboxRequest { }, Create(ap::CreateActivity, db::User, sqlx::SqliteConnection), Like(ap::LikeActivity, db::User), - Boost(ap::BoostActivity, db::User) + Boost(ap::BoostActivity, db::User, sqlx::SqliteConnection) } fn key_id(user: &db::User) -> String { @@ -123,6 +123,7 @@ pub async fn handle_inbox_request( user, content: post.content, created_at, + attachments: vec![], boosted_post: None }; @@ -134,8 +135,165 @@ pub async fn handle_inbox_request( InboxRequest::Like(_, _) => { warn!("unimplemented Like in inbox"); }, - InboxRequest::Boost(_, _) => { - warn!("unimplemented Boost in inbox"); + InboxRequest::Boost(activity, target, mut conn) => { + let id = key_id(&target); + let http = HttpWrapper::new(http, &id); + let person = http.get_person(&activity.actor).await.unwrap(); + let rmt = person.remote_info(); + + let boosted_note = http.get_note(&activity.object).await.unwrap(); + let boosted_author = if let Some(attributed_to) = &boosted_note.attributed_to { + http.get_person(attributed_to).await.map_err(|e| { + error!("failed to fetch attributed_to {}: {}", + attributed_to, + e.to_string() + ); + () + }) + .ok() + } else { + None + }.unwrap(); + + let boosted_rmt = boosted_author.remote_info(); + + event!(Level::INFO, + boosted_by = rmt.acct, + op = boosted_rmt.acct, + "recording boost" + ); + + let boosted_post = { + let actor_uri = boosted_author.obj.id; + let actor = db::Actor { + id: actor_uri, + inbox: boosted_author.inbox, + outbox: boosted_author.outbox + }; + + make::new_actor(actor.clone(), &mut conn) + .await + .unwrap(); + + let user = get::user_by_actor_uri(actor.id.clone(), &mut conn) + .await + .unwrap_or_else(|_| { + db::User { + id: ObjectUuid(crate::new_id()), + actor, + username: boosted_author.preferred_username, + display_name: boosted_author.name, + acct: boosted_rmt.acct, + remote: boosted_rmt.is_remote, + url: boosted_rmt.web_url, + // FIXME: Come from boosted_author + created_at: crate::ap::now(), + icon_url: boosted_author.icon.map(|ic| ic.url) + .unwrap_or("https//ferri.amy.mov/assets/pfp.png".to_string()), + posts: db::UserPosts { + last_post_at: None + } + } + }); + + + make::new_user(user.clone(), &mut conn) + .await + .unwrap(); + + let id = crate::new_id(); + let created_at = DateTime::parse_from_rfc3339(&boosted_note.ts) + .map(|dt| dt.to_utc()) + .unwrap(); + + let attachments = boosted_note.attachment + .into_iter() + .map(|at| { + db::Attachment { + id: ObjectUuid(crate::new_id()), + post_id: ObjectUuid(id.clone()), + url: at.url, + media_type: Some(at.media_type), + sensitive: at.sensitive, + alt: at.summary + } + }) + .collect::>(); + + db::Post { + id: ObjectUuid(id), + uri: boosted_note.obj.id, + user, + attachments, + content: boosted_note.content, + created_at, + boosted_post: None + + } + }; + + make::new_post(boosted_post.clone(), &mut conn).await.unwrap(); + + let base_note = { + let actor_uri = person.obj.id.clone(); + let actor = db::Actor { + id: actor_uri, + inbox: person.inbox, + outbox: person.outbox + }; + + make::new_actor(actor.clone(), &mut conn) + .await + .unwrap(); + + let user = get::user_by_actor_uri(actor.id.clone(), &mut conn) + .await + .unwrap_or_else(|_| { + db::User { + id: ObjectUuid(crate::new_id()), + actor, + username: person.preferred_username, + display_name: person.name, + acct: rmt.acct, + remote: rmt.is_remote, + url: rmt.web_url, + created_at: crate::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 + } + } + }); + + + make::new_user(user.clone(), &mut conn) + .await + .unwrap(); + + let id = crate::new_id(); + let created_at = DateTime::parse_from_rfc3339(&activity.published) + .map(|dt| dt.to_utc()) + .unwrap(); + + db::Post { + id: ObjectUuid(id.clone()), + uri: ObjectUri( + format!("https://ferri.amy.mov/users/{}/posts/{}", + person.obj.id.0, + id + ) + ), + user, + attachments: vec![], + content: String::new(), + created_at, + boosted_post: Some(boosted_post.id.clone()) + + } + }; + + make::new_post(base_note, &mut conn).await.unwrap(); }, } } diff --git a/ferri-main/src/types/get.rs b/ferri-main/src/types/get.rs index 18627f4..4115e98 100644 --- a/ferri-main/src/types/get.rs +++ b/ferri-main/src/types/get.rs @@ -207,8 +207,29 @@ pub async fn posts_for_user_id( .fetch_all(&mut *conn) .await .unwrap(); - + for record in posts { + let attachments = sqlx::query!( + "SELECT * FROM attachment WHERE post_id = ?", + record.post_id + ) + .fetch_all(&mut *conn) + .await + .unwrap(); + + let attachments = attachments.into_iter() + .map(|at| { + db::Attachment { + id: ObjectUuid(at.id), + post_id: ObjectUuid(at.post_id), + url: at.url, + media_type: Some(at.media_type), + sensitive: at.marked_sensitive, + alt: at.alt + } + }) + .collect::>(); + let user_created = parse_ts(record.user_created) .expect("no db corruption"); @@ -233,6 +254,7 @@ pub async fn posts_for_user_id( last_post_at: None } }, + attachments, content: record.content, created_at: parse_ts(record.post_created).unwrap(), boosted_post: None diff --git a/ferri-main/src/types/make.rs b/ferri-main/src/types/make.rs index d569f91..a905dc9 100644 --- a/ferri-main/src/types/make.rs +++ b/ferri-main/src/types/make.rs @@ -68,6 +68,28 @@ pub async fn new_follow( Ok(follow) } +pub async fn new_attachment( + attachment: db::Attachment, + conn: &mut SqliteConnection +) -> Result { + sqlx::query!( + r#" + INSERT INTO attachment (id, post_id, url, media_type, marked_sensitive, alt) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + "#, + attachment.id.0, + attachment.post_id.0, + attachment.url, + attachment.media_type, + attachment.sensitive, + attachment.alt + ) + .execute(conn) + .await + .map_err(|e| DbError::CreationError(e.to_string()))?; + + Ok(attachment) +} pub async fn new_post( post: db::Post, @@ -88,11 +110,14 @@ pub async fn new_post( post.content, ts, boosted - ) - .execute(conn) - .await - .map_err(|e| DbError::CreationError(e.to_string()))?; + .execute(&mut *conn) + .await + .map_err(|e| DbError::CreationError(e.to_string()))?; + + for attachment in post.attachments.clone() { + new_attachment(attachment, &mut *conn).await?; + } Ok(post) } diff --git a/ferri-main/src/types/mod.rs b/ferri-main/src/types/mod.rs index 666b62e..326c8f2 100644 --- a/ferri-main/src/types/mod.rs +++ b/ferri-main/src/types/mod.rs @@ -98,6 +98,16 @@ pub mod db { pub posts: UserPosts, } + #[derive(Debug, Eq, PartialEq, Clone)] + pub struct Attachment { + pub id: ObjectUuid, + pub post_id: ObjectUuid, + pub url: String, + pub media_type: Option, + pub sensitive: bool, + pub alt: Option + } + #[derive(Debug, Eq, PartialEq, Clone)] pub struct Post { pub id: ObjectUuid, @@ -105,7 +115,8 @@ pub mod db { pub user: User, pub content: String, pub created_at: DateTime, - pub boosted_post: Option + pub boosted_post: Option, + pub attachments: Vec } } @@ -203,6 +214,25 @@ pub mod ap { pub object: String, } + #[derive(Serialize, Deserialize, Debug)] + pub enum PostAttachmentType { + Document + } + + #[derive(Serialize, Deserialize, Debug)] + #[serde(rename_all = "camelCase")] + pub struct PostAttachment { + #[serde(rename = "type")] + pub ty: PostAttachmentType, + + pub media_type: String, + pub url: String, + pub name: String, + pub summary: Option, + #[serde(default)] + pub sensitive: bool + } + #[derive(Serialize, Deserialize, Debug)] pub struct Post { #[serde(flatten)] @@ -217,6 +247,8 @@ pub mod ap { pub to: Vec, pub cc: Vec, + pub attachment: Vec, + #[serde(rename = "attributedTo")] pub attributed_to: Option, } diff --git a/ferri-server/src/endpoints/api/timeline.rs b/ferri-server/src/endpoints/api/timeline.rs index 2ca3b03..c6c8fb4 100644 --- a/ferri-server/src/endpoints/api/timeline.rs +++ b/ferri-server/src/endpoints/api/timeline.rs @@ -1,4 +1,5 @@ use crate::{AuthenticatedUser, Db, endpoints::api::user::CredentialAcount}; +use main::types::ObjectUuid; use rocket::{ get, serde::{Deserialize, Serialize, json::Json}, @@ -7,6 +8,16 @@ use rocket_db_pools::Connection; pub type TimelineAccount = CredentialAcount; +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct TimelineStatusAttachment { + id: ObjectUuid, + #[serde(rename = "type")] + ty: String, + url: String, + description: String +} + #[derive(Debug, Serialize, Deserialize)] #[serde(crate = "rocket::serde")] pub struct TimelineStatus { @@ -28,14 +39,14 @@ pub struct TimelineStatus { pub muted: bool, pub bookmarked: bool, pub reblog: Option>, - pub media_attachments: Vec<()>, + pub media_attachments: Vec, pub account: TimelineAccount, } #[get("/timelines/home")] pub async fn home( mut db: Connection, - _user: AuthenticatedUser, + user: AuthenticatedUser, ) -> Json> { #[derive(sqlx::FromRow, Debug)] struct Post { @@ -80,7 +91,7 @@ pub async fn home( JOIN user u ON u.id = p.user_id; "#, ) - .bind("https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9") + .bind(user.actor_id.0) .fetch_all(&mut **db) .await .unwrap(); @@ -90,6 +101,23 @@ pub async fn home( let mut boost: Option> = None; if let Some(ref boosted_id) = record.boosted_post_id { let record = posts.iter().find(|p| &p.post_id == boosted_id).unwrap(); + let attachments = sqlx::query!( + "SELECT * FROM attachment WHERE post_id = ?1", + boosted_id + ) + .fetch_all(&mut **db) + .await + .unwrap() + .into_iter() + .map(|at| { + TimelineStatusAttachment { + id: ObjectUuid(at.id), + url: at.url, + ty: "image".to_string(), + description: at.alt.unwrap_or(String::new()) + } + }) + .collect::>(); boost = Some(Box::new(TimelineStatus { id: record.post_id.clone(), @@ -110,7 +138,7 @@ pub async fn home( reblog: boost, muted: false, bookmarked: false, - media_attachments: vec![], + media_attachments: attachments, account: CredentialAcount { id: record.user_id.clone(), username: record.username.clone(), @@ -134,6 +162,7 @@ pub async fn home( })) } + // Don't send the empty boost source posts if !record.is_boost_source { out.push(TimelineStatus { id: record.post_id.clone(), diff --git a/ferri-server/src/endpoints/inbox.rs b/ferri-server/src/endpoints/inbox.rs index 0c2670f..f712e38 100644 --- a/ferri-server/src/endpoints/inbox.rs +++ b/ferri-server/src/endpoints/inbox.rs @@ -83,7 +83,7 @@ pub async fn inbox( ap::ActivityType::Announce => { let activity = deser::(&body); let msg = QueueMessage::Inbound( - InboxRequest::Boost(activity, user) + InboxRequest::Boost(activity, user, conn) ); queue.0.send(msg).await; diff --git a/ferri-server/src/endpoints/old_code.rs b/ferri-server/src/endpoints/old_code.rs new file mode 100644 index 0000000..b2e8984 --- /dev/null +++ b/ferri-server/src/endpoints/old_code.rs @@ -0,0 +1,388 @@ +fn handle_delete_activity(activity: ap::DeleteActivity) { + warn!(?activity, "unimplemented delete activity"); +} + +async fn create_actor( + user: &ap::Person, + actor: &str, + conn: impl sqlx::Executor<'_, Database = Sqlite>, +) { + sqlx::query!( + r#" + INSERT INTO actor (id, inbox, outbox) + VALUES ( ?1, ?2, ?3 ) + ON CONFLICT(id) DO NOTHING; + "#, + actor, + user.inbox, + user.outbox + ) + .execute(conn) + .await + .unwrap(); +} + +async fn create_user( + user: &ap::Person, + actor: &str, + conn: impl sqlx::Executor<'_, Database = Sqlite>, +) { + // HACK: Allow us to formulate a `user@host` username by assuming the actor is on the same host as the user + let url = Url::parse(actor).unwrap(); + let host = url.host_str().unwrap(); + info!( + "creating user '{}'@'{}' ({:#?})", + user.preferred_username, host, user + ); + + let (acct, remote) = if host != "ferri.amy.mov" { + (format!("{}@{}", user.preferred_username, host), true) + } else { + (user.preferred_username.clone(), false) + }; + + 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 + let ts = main::ap::new_ts(); + sqlx::query!( + r#" + INSERT INTO user ( + id, acct, url, remote, username, + actor_id, display_name, created_at, icon_url + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(actor_id) DO NOTHING; + "#, + uuid, + acct, + url, + remote, + user.preferred_username, + actor, + user.name, + ts, + icon_url + ) + .execute(conn) + .await + .unwrap(); +} + +async fn create_follow( + activity: &ap::FollowActivity, + conn: impl sqlx::Executor<'_, Database = Sqlite>, +) { + sqlx::query!( + r#" + INSERT INTO follow (id, follower_id, followed_id) + VALUES ( ?1, ?2, ?3 ) + ON CONFLICT(id) DO NOTHING; + "#, + activity.obj.id.0, + activity.actor, + activity.object + ) + .execute(conn) + .await + .unwrap(); +} + +struct RemoteInfo { + acct: String, + web_url: String, + is_remote: bool, +} + +fn get_remote_info(actor_url: &str, person: &ap::Person) -> RemoteInfo { + let url = Url::parse(actor_url).unwrap(); + let host = url.host_str().unwrap(); + + let (acct, remote) = if host != "ferri.amy.mov" { + (format!("{}@{}", person.preferred_username, host), true) + } else { + (person.preferred_username.clone(), false) + }; + + let url = format!("https://ferri.amy.mov/{}", acct); + + RemoteInfo { + acct: acct.to_string(), + web_url: url, + is_remote: remote, + } +} + +async fn resolve_actor<'a>( + actor_url: &str, + http: &HttpWrapper<'a>, + conn: &mut SqliteConnection, +) -> Result { + let person = { + let res = http.get_person(actor_url).await; + if let Err(e) = res { + error!("could not load user {}: {}", actor_url, e.to_string()); + return Err(DbError::FetchError(format!( + "could not load user {}: {}", + actor_url, e + ))); + } + + res.unwrap() + }; + + let user_id = ObjectUuid::new(); + let remote_info = get_remote_info(actor_url, &person); + + let actor = db::Actor { + id: ObjectUri(actor_url.to_string()), + inbox: person.inbox.clone(), + outbox: person.outbox.clone(), + }; + + info!("creating actor {}", actor_url); + + let actor = make::new_actor(actor.clone(), conn).await.unwrap_or(actor); + + info!("creating user {} ({:#?})", remote_info.acct, person); + + let user = db::User { + id: user_id, + actor, + username: person.name, + display_name: person.preferred_username, + acct: remote_info.acct, + 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 }, + }; + + Ok(make::new_user(user.clone(), conn).await.unwrap_or(user)) +} + +async fn handle_follow_activity<'a>( + followed_account: &str, + activity: ap::FollowActivity, + http: HttpWrapper<'a>, + mut db: Connection, +) { + let actor = resolve_actor(&activity.actor, &http, &mut db) + .await + .unwrap(); + + info!("{:?} follows {}", actor, followed_account); + + create_follow(&activity, &mut **db).await; + + let follower = main::ap::User::from_actor_id(&activity.actor, &mut **db).await; + let followed = main::ap::User::from_id(followed_account, &mut **db) + .await + .unwrap(); + let outbox = main::ap::Outbox::for_user(followed.clone(), http.client()); + + let activity = main::ap::Activity { + id: format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()), + ty: main::ap::ActivityType::Accept, + object: activity.obj.id.0, + ..Default::default() + }; + + let req = main::ap::OutgoingActivity { + signed_by: format!( + "https://ferri.amy.mov/users/{}#main-key", + followed.username() + ), + req: activity, + to: follower.actor().clone(), + }; + + req.save(&mut **db).await; + outbox.post(req).await; +} + +async fn handle_like_activity(activity: ap::LikeActivity, mut db: Connection) { + warn!(?activity, "unimplemented like activity"); + + let target_post = sqlx::query!("SELECT * FROM post WHERE uri = ?1", activity.object) + .fetch_one(&mut **db) + .await; + + if let Ok(post) = target_post { + warn!(?post, "tried to like post"); + } else { + warn!(post = ?activity.object, "could not find post"); + } +} + +async fn handle_boost_activity<'a>( + activity: ap::BoostActivity, + http: HttpWrapper<'a>, + mut db: Connection, +) { + let key_id = "https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9#main-key"; + dbg!(&activity); + let post = http + .client() + .get(&activity.object) + .activity() + .sign(key_id) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + info!("{}", post); + + let post = serde_json::from_str::(&post); + if let Err(e) = post { + error!(?e, "when decoding post"); + return; + } + + let post = post.unwrap(); + + info!("{:#?}", post); + let attribution = post.attributed_to.unwrap(); + + let post_user = http.get_person(&attribution).await; + if let Err(e) = post_user { + error!( + "could not load post_user {}: {}", + attribution, + e.to_string() + ); + return; + } + let post_user = post_user.unwrap(); + + let user = http.get_person(&activity.actor).await; + if let Err(e) = user { + error!("could not load actor {}: {}", activity.actor, e.to_string()); + return; + } + let user = user.unwrap(); + + debug!("creating actor {}", activity.actor); + create_actor(&user, &activity.actor, &mut **db).await; + + debug!("creating user {}", activity.actor); + create_user(&user, &activity.actor, &mut **db).await; + + debug!("creating actor {}", attribution); + create_actor(&post_user, &attribution, &mut **db).await; + + debug!("creating user {}", attribution); + create_user(&post_user, &attribution, &mut **db).await; + + let attributed_user = main::ap::User::from_actor_id(&attribution, &mut **db).await; + let actor_user = main::ap::User::from_actor_id(&activity.actor, &mut **db).await; + + let base_id = main::ap::new_id(); + let now = main::ap::new_ts(); + + let reblog_id = main::ap::new_id(); + + let attr_id = attributed_user.id(); + // HACK: ON CONFLICT is to avoid duplicate remote posts coming in + // check this better in future + sqlx::query!( + " + INSERT INTO post (id, uri, user_id, content, created_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(uri) DO NOTHING + ", + reblog_id, + post.obj.id.0, + attr_id, + post.content, + post.ts + ) + .execute(&mut **db) + .await + .unwrap(); + + let uri = format!( + "https://ferri.amy.mov/users/{}/posts/{}", + actor_user.id(), + base_id + ); + let user_id = actor_user.id(); + + info!("inserting post with id {} uri {}", base_id, uri); + + sqlx::query!( + " + INSERT INTO post (id, uri, user_id, content, created_at, boosted_post_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ", + base_id, + uri, + user_id, + "", + now, + reblog_id + ) + .execute(&mut **db) + .await + .unwrap(); +} + +async fn handle_create_activity<'a>( + activity: ap::CreateActivity, + http: HttpWrapper<'a>, + mut db: Connection, +) { + assert!(activity.object.ty == ap::ActivityType::Note); + debug!("resolving user {}", activity.actor); + + let user = http.get_person(&activity.actor).await; + if let Err(e) = user { + error!("could not load user {}: {}", activity.actor, e.to_string()); + return; + } + + let user = user.unwrap(); + + debug!("creating actor {}", activity.actor); + create_actor(&user, &activity.actor, &mut **db).await; + + debug!("creating user {}", activity.actor); + create_user(&user, &activity.actor, &mut **db).await; + + let user = main::ap::User::from_actor_id(&activity.actor, &mut **db).await; + debug!("user created {:?}", user); + + 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.obj.id.0; + + info!(post_id, "creating post"); + + sqlx::query!( + r#" + INSERT INTO post (id, uri, user_id, content, created_at) + VALUES (?1, ?2, ?3, ?4, ?5) + "#, + post_id, + uri, + user_id, + content, + now + ) + .execute(&mut **db) + .await + .unwrap(); +} diff --git a/ferri-server/src/endpoints/user.rs b/ferri-server/src/endpoints/user.rs index b2d6dfa..adc89a6 100644 --- a/ferri-server/src/endpoints/user.rs +++ b/ferri-server/src/endpoints/user.rs @@ -126,6 +126,7 @@ pub async fn post( context: as_context(), id: ObjectUri(config.post_url(uuid, &post.id)), }, + attachment: vec![], attributed_to: Some(config.user_url(uuid)), ty: ap::ActivityType::Note, content: post.content, diff --git a/migrations/20250504102047_add_attachment.sql b/migrations/20250504102047_add_attachment.sql new file mode 100644 index 0000000..da7f46b --- /dev/null +++ b/migrations/20250504102047_add_attachment.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS attachment +( + -- UUID + id TEXT PRIMARY KEY NOT NULL, + post_id TEXT NOT NULL, + url TEXT NOT NULL, + media_type TEXT NOT NULL, + marked_sensitive BOOL NOT NULL, + alt TEXT, + + FOREIGN KEY(post_id) REFERENCES post(id) +); +