feat: store AP note extensions in JSONB and render movies-diary posts as rich cards
This commit is contained in:
@@ -14,6 +14,36 @@ use domain::ports::{EventPublisher, TagRepository};
|
||||
use domain::value_objects::UserId;
|
||||
use k_ap::ApObjectHandler;
|
||||
|
||||
fn extract_note_extensions(obj: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
const STANDARD: &[&str] = &[
|
||||
"type",
|
||||
"id",
|
||||
"attributedTo",
|
||||
"content",
|
||||
"published",
|
||||
"to",
|
||||
"cc",
|
||||
"inReplyTo",
|
||||
"sensitive",
|
||||
"summary",
|
||||
"tag",
|
||||
"url",
|
||||
"@context",
|
||||
"mediaType",
|
||||
];
|
||||
let extensions: serde_json::Map<String, serde_json::Value> = obj
|
||||
.as_object()?
|
||||
.iter()
|
||||
.filter(|(k, _)| !STANDARD.contains(&k.as_str()))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
if extensions.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::Value::Object(extensions))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ThoughtsObjectHandler {
|
||||
repo: Arc<dyn ActivityPubRepository>,
|
||||
urls: ThoughtsUrls,
|
||||
@@ -118,6 +148,7 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let note_extensions = extract_note_extensions(&object);
|
||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||
let author_id = self
|
||||
.repo
|
||||
@@ -153,6 +184,7 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
content_warning: note.summary,
|
||||
visibility,
|
||||
in_reply_to: note.in_reply_to.as_ref().map(|u| u.as_str()),
|
||||
note_extensions,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
@@ -408,3 +440,46 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod extract_tests {
|
||||
use super::extract_note_extensions;
|
||||
|
||||
#[test]
|
||||
fn extracts_non_standard_fields() {
|
||||
let obj = serde_json::json!({
|
||||
"type": "Note",
|
||||
"id": "https://example.com/notes/1",
|
||||
"content": "hello",
|
||||
"published": "2025-01-01T00:00:00Z",
|
||||
"movieTitle": "Dune",
|
||||
"rating": 5,
|
||||
"posterUrl": "https://example.com/poster.jpg"
|
||||
});
|
||||
let ext = extract_note_extensions(&obj).unwrap();
|
||||
assert_eq!(ext["movieTitle"], "Dune");
|
||||
assert_eq!(ext["rating"], 5);
|
||||
assert_eq!(ext["posterUrl"], "https://example.com/poster.jpg");
|
||||
assert!(ext.get("type").is_none());
|
||||
assert!(ext.get("content").is_none());
|
||||
assert!(ext.get("id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_standard_only_note() {
|
||||
let obj = serde_json::json!({
|
||||
"type": "Note",
|
||||
"content": "hello",
|
||||
"published": "2025-01-01T00:00:00Z",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
"tag": []
|
||||
});
|
||||
assert!(extract_note_extensions(&obj).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_non_object() {
|
||||
let obj = serde_json::json!("not an object");
|
||||
assert!(extract_note_extensions(&obj).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct AcceptNoteInput<'a> {
|
||||
pub content_warning: Option<String>,
|
||||
pub visibility: &'a str,
|
||||
pub in_reply_to: Option<&'a str>,
|
||||
pub note_extensions: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
|
||||
|
||||
@@ -10,6 +10,7 @@ sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
@@ -52,6 +52,7 @@ struct FeedRow {
|
||||
reply_count: i64,
|
||||
liked_by_viewer: bool,
|
||||
boosted_by_viewer: bool,
|
||||
note_extensions: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||
@@ -67,7 +68,7 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||
t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\
|
||||
t.in_reply_to_id,\n\
|
||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\
|
||||
t.created_at AS thought_created_at, t.updated_at,\n\
|
||||
t.created_at AS thought_created_at, t.updated_at, t.note_extensions,\n\
|
||||
u.id AS author_id, u.username, u.email, u.password_hash,\n\
|
||||
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\
|
||||
u.local AS author_local,\n\
|
||||
@@ -92,6 +93,7 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
||||
local: r.t_local,
|
||||
created_at: r.thought_created_at,
|
||||
updated_at: r.updated_at,
|
||||
note_extensions: r.note_extensions,
|
||||
};
|
||||
let author = User {
|
||||
id: UserId::from_uuid(r.author_id),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE thoughts ADD COLUMN note_extensions JSONB;
|
||||
@@ -65,6 +65,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
local: true,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
note_extensions: None,
|
||||
},
|
||||
author_username: Username::from_trusted(r.username),
|
||||
})
|
||||
@@ -130,6 +131,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
local: true,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
note_extensions: None,
|
||||
},
|
||||
author_username: Username::from_trusted(r.username),
|
||||
})
|
||||
@@ -220,6 +222,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
content_warning,
|
||||
visibility,
|
||||
in_reply_to,
|
||||
note_extensions,
|
||||
} = input;
|
||||
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
||||
let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
|
||||
@@ -236,8 +239,8 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
None => (None, None),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url)
|
||||
VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10) ON CONFLICT(ap_id) DO NOTHING",
|
||||
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url,note_extensions)
|
||||
VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10,$11) ON CONFLICT(ap_id) DO NOTHING",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4())
|
||||
.bind(author_id.as_uuid())
|
||||
@@ -249,6 +252,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
.bind(visibility)
|
||||
.bind(in_reply_to_id)
|
||||
.bind(&in_reply_to_url)
|
||||
.bind(note_extensions)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
@@ -25,6 +25,7 @@ async fn accept_and_retract_note(pool: sqlx::PgPool) {
|
||||
content_warning: None,
|
||||
visibility: "public",
|
||||
in_reply_to: None,
|
||||
note_extensions: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -55,6 +56,7 @@ async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
|
||||
content_warning: None,
|
||||
visibility: "public",
|
||||
in_reply_to: None,
|
||||
note_extensions: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -35,6 +35,7 @@ struct FeedRow {
|
||||
t_local: bool,
|
||||
thought_created_at: DateTime<Utc>,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
note_extensions: Option<serde_json::Value>,
|
||||
author_id: uuid::Uuid,
|
||||
username: String,
|
||||
email: String,
|
||||
@@ -82,6 +83,7 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||
t.in_reply_to_id,
|
||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
||||
t.created_at AS thought_created_at, t.updated_at,
|
||||
t.note_extensions,
|
||||
u.id AS author_id,
|
||||
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
|
||||
THEN '@' || ra.handle ||
|
||||
@@ -118,6 +120,7 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
||||
local: r.t_local,
|
||||
created_at: r.thought_created_at,
|
||||
updated_at: r.updated_at,
|
||||
note_extensions: r.note_extensions,
|
||||
};
|
||||
let author = User {
|
||||
id: UserId::from_uuid(r.author_id),
|
||||
|
||||
@@ -34,6 +34,7 @@ pub(crate) struct ThoughtRow {
|
||||
pub local: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
pub note_extensions: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl TryFrom<ThoughtRow> for Thought {
|
||||
@@ -50,12 +51,13 @@ impl TryFrom<ThoughtRow> for Thought {
|
||||
local: r.local,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
note_extensions: r.note_extensions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const THOUGHT_SELECT: &str =
|
||||
"SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts";
|
||||
"SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions FROM thoughts";
|
||||
|
||||
#[async_trait]
|
||||
impl ThoughtRepository for PgThoughtRepository {
|
||||
@@ -117,11 +119,11 @@ impl ThoughtRepository for PgThoughtRepository {
|
||||
sqlx::query_as::<_, ThoughtRow>(
|
||||
"WITH RECURSIVE thread AS (
|
||||
SELECT id,user_id,content,in_reply_to_id,
|
||||
visibility,content_warning,sensitive,local,created_at,updated_at
|
||||
visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions
|
||||
FROM thoughts WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT t.id,t.user_id,t.content,t.in_reply_to_id,
|
||||
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at
|
||||
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at,t.note_extensions
|
||||
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
|
||||
)
|
||||
SELECT * FROM thread ORDER BY created_at ASC",
|
||||
|
||||
Reference in New Issue
Block a user