426a121f23b76660d4e726c990a4ce183c1c0384 / frogue / src / activitypub / remote / notes.rs
Raw
//! Remote note storage and management.
//!
//! Handles incoming Create, Update, and Delete activities for notes from
//! remote actors. Only stores notes from actors we follow with accepted state.
use chrono::{DateTime, FixedOffset, Utc};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, Set,
};
use tracing::{debug, info, instrument, warn};
use crate::activitypub::types::{
CreateActivity, CreateEmbedded, CreateObject, DeleteActivity, DeleteObject, UpdateActivity,
UpdateEmbedded, UpdateObject,
};
use crate::entities::prelude::{ApFollowing, ApRemoteActors, ApRemoteNotes};
use crate::entities::{ap_following, ap_remote_actors, ap_remote_notes};
/// Store a remote note in the database.
///
/// This is a shared function used by both Create and Announce handlers.
/// Only stores notes from actors we follow with accepted state.
/// The content is sanitized to prevent XSS attacks.
#[instrument(skip(db))]
pub async fn store_note(
db: &DatabaseConnection,
verified_actor_url: &str,
object_id: String,
content: String,
published: DateTime<FixedOffset>,
object_type: &str,
) -> Result<(), String> {
// Only handle Note objects
if object_type != "Note" {
debug!(object_type, "ignoring non-Note object");
return Ok(());
}
// Verify the actor is in our following list with accepted state
let remote_actor = ApRemoteActors::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(verified_actor_url))
.one(db)
.await
.map_err(|e| format!("failed to find remote actor: {}", e))?
.ok_or("remote actor not found")?;
let following = ApFollowing::find()
.filter(ap_following::Column::RemoteActorId.eq(remote_actor.id))
.filter(ap_following::Column::State.eq("accepted"))
.one(db)
.await
.map_err(|e| format!("failed to check following status: {}", e))?;
if following.is_none() {
debug!(
actor_url = %verified_actor_url,
"dropping note from non-followed actor"
);
return Ok(());
}
// Sanitize the content
let sanitized_content = crate::markdown::sanitize_html(&content);
// Insert the note (idempotent via unique constraint on object_url)
let note_model = ap_remote_notes::ActiveModel {
remote_actor_id: Set(remote_actor.id),
object_url: Set(object_id.clone()),
content_html: Set(sanitized_content.clone()),
published: Set(published),
..Default::default()
};
match note_model.insert(db).await {
Ok(_) => {
info!(
object_url = %object_id,
actor_url = %verified_actor_url,
"stored remote note"
);
Ok(())
}
Err(e) if e.to_string().contains("UNIQUE constraint failed") => {
debug!(
object_url = %object_id,
"note already stored (duplicate)"
);
Ok(())
}
Err(e) => Err(format!("failed to insert remote note: {}", e)),
}
}
/// Handle an incoming Create activity for a Note.
///
/// Only stores notes from actors we follow with accepted state. The content is
/// sanitized to prevent XSS attacks.
#[instrument(skip(db, http_client, create, public_url, signer))]
pub async fn handle_create(
db: &DatabaseConnection,
http_client: &reqwest_middleware::ClientWithMiddleware,
verified_actor_url: &str,
public_url: &str,
user_name: &str,
signer: &crate::entities::ap_actors::Model,
create: &CreateActivity,
) -> Result<(), String> {
// Extract the object
let (object_id, content, published, object_type) = match &create.object {
CreateObject::Url(url) => {
// Fetch the object with signed GET
debug!(url, "fetching URL-only object");
let json = match crate::activitypub::remote::signed_get(
http_client,
url,
public_url,
signer,
user_name,
)
.await
{
Ok(json) => json,
Err(e) => {
warn!(error = %e, url, "failed to fetch URL-only object");
return Err(format!("failed to fetch object: {}", e));
}
};
// Parse the fetched object
let parsed: CreateEmbedded = match serde_json::from_value(json) {
Ok(obj) => obj,
Err(e) => {
warn!(error = %e, url, "failed to parse fetched object");
return Err(format!("failed to parse object: {}", e));
}
};
let published = if let Some(pub_str) = &parsed.published {
pub_str
.parse::<DateTime<Utc>>()
.map_err(|e| format!("failed to parse published timestamp: {}", e))?
.into()
} else {
Utc::now().into()
};
(
parsed.id.clone(),
parsed.content.clone().unwrap_or_default(),
published,
parsed.kind.clone(),
)
}
CreateObject::Embedded(embedded) => {
let published = if let Some(pub_str) = &embedded.published {
pub_str
.parse::<DateTime<Utc>>()
.map_err(|e| format!("failed to parse published timestamp: {}", e))?
.into()
} else {
Utc::now().into()
};
(
embedded.id.clone(),
embedded.content.clone().unwrap_or_default(),
published,
embedded.kind.clone(),
)
}
};
store_note(
db,
verified_actor_url,
object_id,
content,
published,
&object_type,
)
.await
}
/// Handle an incoming Update activity for a Note.
///
/// Only updates notes that we've previously stored. The content is re-sanitized.
#[instrument(skip(db, http_client, update, public_url, signer))]
pub async fn handle_update(
db: &DatabaseConnection,
http_client: &reqwest_middleware::ClientWithMiddleware,
verified_actor_url: &str,
public_url: &str,
user_name: &str,
signer: &crate::entities::ap_actors::Model,
update: &UpdateActivity,
) -> Result<(), String> {
// Extract the object
let (object_id, content, published, object_type) = match &update.object {
UpdateObject::Url(url) => {
// Fetch the object with signed GET (best-effort)
debug!(url, "fetching URL-only object for Update");
let json = match crate::activitypub::remote::signed_get(
http_client,
url,
public_url,
signer,
user_name,
)
.await
{
Ok(json) => json,
Err(e) => {
warn!(error = %e, url, "failed to fetch URL-only object for Update");
return Ok(()); // Best-effort, skip with log
}
};
// Parse the fetched object
let parsed: UpdateEmbedded = match serde_json::from_value(json) {
Ok(obj) => obj,
Err(e) => {
warn!(error = %e, url, "failed to parse fetched object");
return Ok(()); // Best-effort, skip with log
}
};
let published = if let Some(pub_str) = &parsed.published {
pub_str
.parse::<DateTime<Utc>>()
.map_err(|e| format!("failed to parse published timestamp: {}", e))?
.into()
} else {
Utc::now().into()
};
(
parsed.id.clone(),
parsed.content.clone().unwrap_or_default(),
published,
parsed.kind.clone(),
)
}
UpdateObject::Embedded(embedded) => {
let published = if let Some(pub_str) = &embedded.published {
pub_str
.parse::<DateTime<Utc>>()
.map_err(|e| format!("failed to parse published timestamp: {}", e))?
.into()
} else {
Utc::now().into()
};
(
embedded.id.clone(),
embedded.content.clone().unwrap_or_default(),
published,
embedded.kind.clone(),
)
}
};
// Only handle Note objects
if object_type != "Note" {
debug!(object_type, "ignoring Update of non-Note object");
return Ok(());
}
// Find the existing note
let existing_note = ApRemoteNotes::find()
.filter(ap_remote_notes::Column::ObjectUrl.eq(&object_id))
.one(db)
.await
.map_err(|e| format!("failed to find remote note: {}", e))?
.ok_or_else(|| {
debug!(object_url = %object_id, "Update for unknown note");
"note not found".to_string()
})?;
// Verify the actor owns the note
if existing_note.remote_actor_id != {
let remote_actor = ApRemoteActors::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(verified_actor_url))
.one(db)
.await
.map_err(|e| format!("failed to find remote actor: {}", e))?
.ok_or("remote actor not found")?;
remote_actor.id
} {
warn!(
object_url = %object_id,
actor_url = %verified_actor_url,
"Update from non-owner actor"
);
return Ok(());
}
// Sanitize the updated content
let sanitized_content = crate::markdown::sanitize_html(&content);
// Update the note
let mut active_model: ap_remote_notes::ActiveModel = existing_note.into();
active_model.content_html = Set(sanitized_content);
active_model.published = Set(published);
active_model
.update(db)
.await
.map_err(|e| format!("failed to update remote note: {}", e))?;
info!(
object_url = %object_id,
actor_url = %verified_actor_url,
"updated remote note"
);
Ok(())
}
/// Handle an incoming Delete activity for a Note.
///
/// Removes the note from storage if it exists and the actor owns it.
#[instrument(skip(db, delete))]
pub async fn handle_delete(
db: &DatabaseConnection,
verified_actor_url: &str,
delete: &DeleteActivity,
) -> Result<(), String> {
// Extract the object ID
let object_id = match &delete.object {
DeleteObject::Url(url) => url.clone(),
DeleteObject::Embedded(embedded) => embedded.id.clone(),
};
// Find the existing note
let existing_note = ApRemoteNotes::find()
.filter(ap_remote_notes::Column::ObjectUrl.eq(&object_id))
.one(db)
.await
.map_err(|e| format!("failed to find remote note: {}", e))?
.ok_or_else(|| {
debug!(object_url = %object_id, "Delete for unknown note");
"note not found".to_string()
})?;
// Verify the actor owns the note
let remote_actor = ApRemoteActors::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(verified_actor_url))
.one(db)
.await
.map_err(|e| format!("failed to find remote actor: {}", e))?
.ok_or("remote actor not found")?;
if existing_note.remote_actor_id != remote_actor.id {
warn!(
object_url = %object_id,
actor_url = %verified_actor_url,
"Delete from non-owner actor"
);
return Ok(());
}
// Delete the note
existing_note
.delete(db)
.await
.map_err(|e| format!("failed to delete remote note: {}", e))?;
info!(
object_url = %object_id,
actor_url = %verified_actor_url,
"deleted remote note"
);
Ok(())
}