// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! Moderation reports handling.
use actix_web::HttpResponse;
use sea_orm::ActiveValue::NotSet;
use sea_orm::{ActiveModelTrait, DatabaseConnection, Set};
use tracing::{debug, info, instrument, warn};
use uuid::Uuid;
use crate::activitypub::actor::get_by_url;
use crate::activitypub::types::FlagActivity;
use crate::entities::{ap_actors, reports};
/// Handle an incoming Flag activity (moderation report).
///
/// Creates a moderation report when a remote actor flags content. The report
/// is stored in the database and can be reviewed by instance administrators.
///
/// # Arguments
///
/// * `db` - Database connection
/// * `public_url` - The public URL of this instance
/// * `flag` - The Flag activity containing the report
/// * `signer` - The actor who signed the activity (must match flag.actor)
///
/// # Returns
///
/// Returns 202 Accepted on success (even for duplicates).
#[instrument(name = "inbox.handle_flag", skip(db, flag, signer), fields(flag_id = %flag.id, actor = %signer.user_id.unwrap_or(0)))]
pub async fn handle_flag(
db: &DatabaseConnection,
public_url: &str,
flag: &FlagActivity,
signer: &ap_actors::Model,
) -> HttpResponse {
// Extract reported URLs
let reported_urls = flag.object.urls();
if reported_urls.is_empty() {
warn!("Flag activity contains no reported URLs");
return HttpResponse::Accepted().finish();
}
// The first URL is the reported actor (Mastodon semantics)
let reported_actor_url = reported_urls[0];
debug!(
reported_actor_url = %reported_actor_url,
num_objects = %reported_urls.len(),
"Processing Flag activity"
);
// Resolve the reported actor to a local user if possible
let target_local_user_id = match get_by_url(db, reported_actor_url, public_url).await {
Ok(Some(actor)) => {
info!(
target_user_id = %actor.user_id.unwrap_or(0),
"Resolved reported actor to local user"
);
actor.user_id
}
Ok(None) => {
debug!(
reported_actor_url = %reported_actor_url,
"Reported actor not found locally"
);
None
}
Err(e) => {
warn!(
error = %e,
reported_actor_url = %reported_actor_url,
"Failed to resolve reported actor"
);
None
}
};
// Serialize reported URLs to JSON array
let reported_urls_json = match serde_json::to_string(&reported_urls) {
Ok(json) => json,
Err(e) => {
warn!(error = %e, "Failed to serialize reported URLs");
return HttpResponse::Accepted().finish();
}
};
// Determine if this is a forwarded report (sent to instance actor)
// Instance actor has user_id=None and repo_id=None
let forwarded = if signer.user_id.is_none() && signer.repo_id.is_none() {
1
} else {
0
};
// Create the report
let report = reports::ActiveModel {
id: NotSet,
uuid: Set(Uuid::new_v4().to_string()),
created_at: Set(chrono::Utc::now().into()),
reporter_local_user_id: Set(None),
reporter_remote_actor_url: Set(Some(flag.actor.clone())),
target_local_user_id: Set(target_local_user_id),
target_remote_actor_id: Set(None),
object_urls: Set(reported_urls_json),
comment: Set(flag.content.clone()),
state: Set("open".to_string()),
forwarded: Set(forwarded),
inbound: Set(1),
activity_url: Set(Some(flag.id.clone())),
};
match report.insert(db).await {
Ok(_) => {
info!(
flag_id = %flag.id,
reporter = %flag.actor,
target_user_id = ?target_local_user_id,
forwarded = %forwarded,
"Created moderation report"
);
HttpResponse::Accepted().finish()
}
Err(e) => {
// Check if this is a duplicate (UNIQUE constraint on activity_url)
if e.to_string().contains("UNIQUE constraint failed") {
debug!(
flag_id = %flag.id,
"Duplicate Flag activity, ignoring"
);
return HttpResponse::Accepted().finish();
}
warn!(
error = %e,
flag_id = %flag.id,
"Failed to insert moderation report"
);
HttpResponse::Accepted().finish()
}
}
}