feat: attachments; other cleanup

This commit is contained in:
nullishamy 2025-05-04 12:58:34 +01:00
parent f0e287c78d
commit 8cf7834cfe
Signed by: amy
SSH key fingerprint: SHA256:WmV0uk6WgAQvDJlM8Ld4mFPHZo02CLXXP5VkwQ5xtyk
10 changed files with 687 additions and 15 deletions

View file

@ -75,6 +75,10 @@ impl<'a> HttpWrapper<'a> {
self.get("Person", url).await
}
pub async fn get_note(&self, url: &str) -> Result<ap::Post, HttpError> {
self.get("Note", url).await
}
pub async fn post_activity<T : Serialize + Debug>(
&self,
inbox: &str,

View file

@ -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::<Vec<_>>();
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();
},
}
}

View file

@ -209,6 +209,27 @@ pub async fn posts_for_user_id(
.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::<Vec<_>>();
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

View file

@ -68,6 +68,28 @@ pub async fn new_follow(
Ok(follow)
}
pub async fn new_attachment(
attachment: db::Attachment,
conn: &mut SqliteConnection
) -> Result<db::Attachment, DbError> {
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)
}

View file

@ -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<String>,
pub sensitive: bool,
pub alt: Option<String>
}
#[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<Utc>,
pub boosted_post: Option<ObjectUuid>
pub boosted_post: Option<ObjectUuid>,
pub attachments: Vec<Attachment>
}
}
@ -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<String>,
#[serde(default)]
pub sensitive: bool
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Post {
#[serde(flatten)]
@ -217,6 +247,8 @@ pub mod ap {
pub to: Vec<String>,
pub cc: Vec<String>,
pub attachment: Vec<PostAttachment>,
#[serde(rename = "attributedTo")]
pub attributed_to: Option<String>,
}

View file

@ -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<Box<TimelineStatus>>,
pub media_attachments: Vec<()>,
pub media_attachments: Vec<TimelineStatusAttachment>,
pub account: TimelineAccount,
}
#[get("/timelines/home")]
pub async fn home(
mut db: Connection<Db>,
_user: AuthenticatedUser,
user: AuthenticatedUser,
) -> Json<Vec<TimelineStatus>> {
#[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<Box<TimelineStatus>> = 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::<Vec<_>>();
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(),

View file

@ -83,7 +83,7 @@ pub async fn inbox(
ap::ActivityType::Announce => {
let activity = deser::<ap::BoostActivity>(&body);
let msg = QueueMessage::Inbound(
InboxRequest::Boost(activity, user)
InboxRequest::Boost(activity, user, conn)
);
queue.0.send(msg).await;

View file

@ -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<db::User, DbError> {
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<Db>,
) {
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<Db>) {
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<Db>,
) {
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::<ap::Post>(&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<Db>,
) {
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();
}

View file

@ -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,

View file

@ -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)
);