🐸 Frogue(Fed)

~ololduck/Frogue

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, create, public_url, signer))]
pub async fn handle_create(
    db: &DatabaseConnection,
    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(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, update, public_url, signer))]
pub async fn handle_update(
    db: &DatabaseConnection,
    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(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(())
}