540871a8cdc2c7df89bd3c0f5eb39387359db062 / frogue / src / webui / admin.rs
Raw
// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! Admin interface handlers.
//!
//! Handles moderation features: user management, report queue, and federation
//! controls. All routes require AdminUser extractor (user_id = 1 for now).
use std::sync::Arc;
use actix_web::{HttpResponse, Responder, get, post, web};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, Set,
};
use tokio::sync::RwLock;
use tracing::instrument;
use crate::auth::AdminUser;
use crate::entities::{instance_blocks, reports, users};
use crate::views::admin;
use crate::{AppState, state_read};
/// Query the count of open reports for the nav badge
async fn get_open_report_count(db: &sea_orm::DatabaseConnection) -> u64 {
reports::Entity::find()
.filter(reports::Column::State.eq("open"))
.count(db)
.await
.unwrap_or(0)
}
/// GET /admin - Admin home page
#[get("/admin")]
pub async fn admin_home(
admin: AdminUser,
data: web::Data<Arc<RwLock<AppState>>>,
) -> impl Responder {
let state = state_read(&data).await;
let pending_reports = get_open_report_count(&state.db).await;
admin::home(&admin, pending_reports, None).into_response()
}
/// Query parameters for users list pagination
#[derive(Debug, serde::Deserialize)]
pub struct UsersListQuery {
pub page: Option<u64>,
}
/// GET /admin/users - List all users
#[get("/admin/users")]
#[instrument(skip(admin, data))]
pub async fn list_users(
admin: AdminUser,
data: web::Data<Arc<RwLock<AppState>>>,
query: web::Query<UsersListQuery>,
) -> impl Responder {
let state = state_read(&data).await;
let db = &state.db;
let page = query.page.unwrap_or(0);
const PAGE_SIZE: u64 = 50;
let offset = page * PAGE_SIZE;
let users = users::Entity::find()
.order_by_asc(users::Column::Id)
.limit(PAGE_SIZE)
.offset(offset)
.all(db)
.await
.unwrap_or_default();
// Fetch repository and note counts for each user
let repos = crate::entities::repositories::Entity::find()
.all(db)
.await
.unwrap_or_default();
let repo_counts: std::collections::HashMap<i64, usize> =
repos
.iter()
.fold(std::collections::HashMap::new(), |mut acc, r| {
*acc.entry(r.owner_id).or_insert(0) += 1;
acc
});
let ap_actors = crate::entities::ap_actors::Entity::find()
.all(db)
.await
.unwrap_or_default();
let user_actor_ids: std::collections::HashMap<i64, i64> = ap_actors
.iter()
.filter_map(|a| a.user_id.map(|uid| (uid, a.id)))
.collect();
let ap_notes = crate::entities::ap_notes::Entity::find()
.all(db)
.await
.unwrap_or_default();
let note_counts: std::collections::HashMap<i64, usize> =
ap_notes
.iter()
.fold(std::collections::HashMap::new(), |mut acc, n| {
*acc.entry(n.actor_id).or_insert(0) += 1;
acc
});
// Map user_id -> note count via actor_id
let user_note_counts: std::collections::HashMap<i64, usize> = users
.iter()
.map(|u| {
let note_count = user_actor_ids
.get(&u.id)
.and_then(|actor_id| note_counts.get(actor_id))
.copied()
.unwrap_or(0);
(u.id, note_count)
})
.collect();
let pending_reports = get_open_report_count(db).await;
admin::users_list(
&admin,
&users,
&repo_counts,
&user_note_counts,
pending_reports,
None,
)
.into_response()
}
/// POST /admin/users/{id}/delete - Delete a user
///
/// Note: Sessions are stateless cookies; a deleted user's cookie dies on first
/// lookup (name no longer resolves). No revocation needed.
#[post("/admin/users/{id}/delete")]
#[instrument(name = "admin.delete_user", skip(admin, data), fields(user_id))]
pub async fn delete_user(
admin: AdminUser,
data: web::Data<Arc<RwLock<AppState>>>,
id: web::Path<i64>,
) -> impl Responder {
let user_id = id.into_inner();
tracing::Span::current().record("user_id", user_id);
let state = state_read(&data).await;
let db = &state.db;
let public_url = state.public_url();
// Prevent deleting the admin (user_id = 1)
if user_id == admin.id {
return HttpResponse::BadRequest().body("Cannot delete the admin user");
}
// Schedule user deletion with grace period
let grace_period = crate::deletion_grace_period_seconds();
match crate::moderation::schedule_user_deletion(db, public_url, user_id, grace_period).await {
Ok(_) => HttpResponse::Found()
.append_header(("Location", "/admin/users"))
.finish(),
Err(crate::moderation::ScheduleDeletionError::UserNotFound) => {
let users = users::Entity::find()
.order_by_asc(users::Column::Id)
.all(db)
.await
.unwrap_or_default();
let pending_reports = get_open_report_count(db).await;
admin::users_list(
&admin,
&users,
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
pending_reports,
Some("User not found"),
)
.into_response()
}
Err(e) => {
tracing::error!(error = %e, user_id = user_id, "Failed to schedule user deletion");
let users = users::Entity::find()
.order_by_asc(users::Column::Id)
.all(db)
.await
.unwrap_or_default();
let pending_reports = get_open_report_count(db).await;
admin::users_list(
&admin,
&users,
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
pending_reports,
Some("Failed to schedule user deletion"),
)
.into_response()
}
}
}
/// Query parameters for filtering reports
#[derive(serde::Deserialize)]
pub struct ReportFilter {
pub state: Option<String>,
}
/// GET /admin/reports - List all reports
#[get("/admin/reports")]
#[instrument(skip(admin, data, query))]
pub async fn list_reports(
admin: AdminUser,
data: web::Data<Arc<RwLock<AppState>>>,
query: web::Query<ReportFilter>,
) -> impl Responder {
let state = state_read(&data).await;
let db = &state.db;
let reports = if let Some(ref state_filter) = query.state {
if state_filter.is_empty() {
// Show all reports
reports::Entity::find()
.order_by_desc(reports::Column::CreatedAt)
.all(db)
.await
.unwrap_or_default()
} else {
// Filter by state
reports::Entity::find()
.filter(reports::Column::State.eq(state_filter))
.order_by_desc(reports::Column::CreatedAt)
.all(db)
.await
.unwrap_or_default()
}
} else {
// Default: show open reports
reports::Entity::find()
.filter(reports::Column::State.eq("open"))
.order_by_desc(reports::Column::CreatedAt)
.all(db)
.await
.unwrap_or_default()
};
// Fetch all users for resolving reporter/target usernames
let users = users::Entity::find().all(db).await.unwrap_or_default();
// Fetch all remote actors for resolving reporter/target handles
let remote_actors = crate::entities::ap_remote_actors::Entity::find()
.all(db)
.await
.unwrap_or_default();
let pending_reports = get_open_report_count(db).await;
admin::reports_list(
&admin,
&reports,
&users,
&remote_actors,
query.state.as_deref(),
pending_reports,
None,
)
.into_response()
}
/// GET /admin/reports/{uuid} - Report detail view
#[get("/admin/reports/{uuid}")]
#[instrument(skip(admin, data), fields(report_uuid = %uuid.as_str()))]
pub async fn report_detail(
admin: AdminUser,
data: web::Data<Arc<RwLock<AppState>>>,
uuid: web::Path<String>,
) -> impl Responder {
let state = state_read(&data).await;
let db = &state.db;
let public_url = state.public_url();
// Find the report by UUID
let report = match reports::Entity::find()
.filter(reports::Column::Uuid.eq(uuid.as_str()))
.one(db)
.await
{
Ok(Some(report)) => report,
Ok(None) => {
return HttpResponse::NotFound().body("Report not found");
}
Err(e) => {
tracing::error!(error = %e, "Failed to fetch report");
return HttpResponse::InternalServerError().body("Database error");
}
};
// Fetch reporter user (if local)
let reporter_user = if let Some(reporter_id) = report.reporter_local_user_id {
users::Entity::find_by_id(reporter_id)
.one(db)
.await
.unwrap_or(None)
} else {
None
};
// Fetch target user (if local)
let target_user = if let Some(target_id) = report.target_local_user_id {
users::Entity::find_by_id(target_id)
.one(db)
.await
.unwrap_or(None)
} else {
None
};
// Fetch target remote actor (if remote)
let target_remote = if let Some(target_remote_id) = report.target_remote_actor_id {
crate::entities::ap_remote_actors::Entity::find_by_id(target_remote_id)
.one(db)
.await
.unwrap_or(None)
} else {
None
};
// Resolve object URLs to local notes or external links
let object_urls: Vec<String> = serde_json::from_str(&report.object_urls).unwrap_or_default();
let reported_objects = resolve_object_urls(db, &object_urls, public_url).await;
let pending_reports = get_open_report_count(db).await;
admin::report_detail(
&admin,
&report,
reporter_user.as_ref(),
report.reporter_remote_actor_url.as_deref(),
target_user.as_ref(),
target_remote.as_ref(),
&reported_objects,
pending_reports,
None,
)
.into_response()
}
/// POST /admin/reports/{uuid}/resolve - Resolve a report
#[post("/admin/reports/{uuid}/resolve")]
#[instrument(skip(_admin, data), fields(report_uuid = %uuid.as_str()))]
pub async fn resolve_report(
_admin: AdminUser,
data: web::Data<Arc<RwLock<AppState>>>,
uuid: web::Path<String>,
) -> impl Responder {
let report_uuid = uuid.into_inner();
let state = state_read(&data).await;
let db = &state.db;
let report = reports::Entity::find()
.filter(reports::Column::Uuid.eq(&report_uuid))
.one(db)
.await
.unwrap_or(None);
if let Some(report) = report {
let mut active: reports::ActiveModel = report.into();
active.state = sea_orm::ActiveValue::Set("resolved".to_string());
let _ = active.update(db).await;
}
HttpResponse::Found()
.append_header(("Location", "/admin/reports"))
.finish()
}
/// POST /admin/reports/{uuid}/dismiss - Dismiss a report
#[post("/admin/reports/{uuid}/dismiss")]
#[instrument(skip(_admin, data), fields(report_uuid = %uuid.as_str()))]
pub async fn dismiss_report(
_admin: AdminUser,
data: web::Data<Arc<RwLock<AppState>>>,
uuid: web::Path<String>,
) -> impl Responder {
let report_uuid = uuid.into_inner();
let state = state_read(&data).await;
let db = &state.db;
let report = reports::Entity::find()
.filter(reports::Column::Uuid.eq(&report_uuid))
.one(db)
.await
.unwrap_or(None);
if let Some(report) = report {
let mut active: reports::ActiveModel = report.into();
active.state = sea_orm::ActiveValue::Set("dismissed".to_string());
let _ = active.update(db).await;
}
HttpResponse::Found()
.append_header(("Location", "/admin/reports"))
.finish()
}
/// GET /admin/federation - Federation controls
#[get("/admin/federation")]
#[instrument(skip(admin, data))]
pub async fn federation_controls(
admin: AdminUser,
data: web::Data<Arc<RwLock<AppState>>>,
) -> impl Responder {
let state = state_read(&data).await;
let db = &state.db;
let blocks = instance_blocks::Entity::find()
.order_by_desc(instance_blocks::Column::CreatedAt)
.all(db)
.await
.unwrap_or_default();
// Fetch admin remote blocks with remote actor info
let admin_blocks = crate::entities::admin_remote_blocks::Entity::find()
.order_by_desc(crate::entities::admin_remote_blocks::Column::CreatedAt)
.all(db)
.await
.unwrap_or_default();
let remote_actors = crate::entities::ap_remote_actors::Entity::find()
.all(db)
.await
.unwrap_or_default();
let users = users::Entity::find().all(db).await.unwrap_or_default();
let pending_reports = get_open_report_count(db).await;
admin::federation(
&admin,
&blocks,
&admin_blocks,
&remote_actors,
&users,
pending_reports,
None,
)
.into_response()
}
/// POST /admin/federation/block-domain - Block a domain
#[post("/admin/federation/block-domain")]
#[instrument(name = "admin.block_domain", skip(admin, data, form), fields(domain))]
pub async fn block_domain(
admin: AdminUser,
data: web::Data<Arc<RwLock<AppState>>>,
form: web::Form<BlockDomainForm>,
) -> impl Responder {
let state = state_read(&data).await;
let db = &state.db;
// Normalize to a lowercase host: strips scheme, port, path, query,
// fragment.
let Some(domain) = crate::moderation::normalize_domain(&form.domain) else {
return HttpResponse::Found()
.append_header(("Location", "/admin/federation"))
.finish();
};
// Check if already blocked
let existing = instance_blocks::Entity::find()
.filter(instance_blocks::Column::Domain.eq(domain.as_str()))
.one(db)
.await
.unwrap_or(None);
if existing.is_some() {
return HttpResponse::Found()
.append_header(("Location", "/admin/federation"))
.finish();
}
let block = instance_blocks::ActiveModel {
domain: Set(domain.clone()),
reason: Set(if form.reason.is_empty() {
None
} else {
Some(form.reason.clone())
}),
blocked_by: Set(admin.id),
..Default::default()
};
match block.insert(db).await {
Ok(_) => {
tracing::info!(admin = %admin.name, domain = %domain, "Domain blocked");
HttpResponse::Found()
.append_header(("Location", "/admin/federation"))
.finish()
}
Err(e) => {
tracing::error!(error = %e, "Failed to block domain");
HttpResponse::Found()
.append_header(("Location", "/admin/federation"))
.finish()
}
}
}
/// POST /admin/federation/unblock-domain - Unblock a domain
#[post("/admin/federation/unblock-domain")]
#[instrument(skip(admin, data, form), fields(domain))]
pub async fn unblock_domain(
admin: AdminUser,
data: web::Data<Arc<RwLock<AppState>>>,
form: web::Form<UnblockDomainForm>,
) -> impl Responder {
let state = state_read(&data).await;
let db = &state.db;
let domain = crate::moderation::normalize_domain(&form.domain).unwrap_or_default();
tracing::Span::current().record("domain", &domain);
// TODO: In v1, federate Undo(Block) activity before deleting the row
match instance_blocks::Entity::delete_many()
.filter(instance_blocks::Column::Domain.eq(domain.as_str()))
.exec(db)
.await
{
Ok(result) => {
tracing::info!(admin = %admin.name, domain = %domain, rows_affected = result.rows_affected, "Domain unblocked");
}
Err(e) => {
tracing::error!(error = %e, "Failed to unblock domain");
}
}
HttpResponse::Found()
.append_header(("Location", "/admin/federation"))
.finish()
}
/// POST /admin/federation/block-remote-user - Block a remote user
#[post("/admin/federation/block-remote-user")]
#[instrument(
name = "admin.block_remote_user",
skip(admin, data, form),
fields(actor_url)
)]
pub async fn block_remote_user(
admin: AdminUser,
data: web::Data<Arc<RwLock<AppState>>>,
form: web::Form<BlockRemoteUserForm>,
) -> impl Responder {
let state = state_read(&data).await;
let db = &state.db;
let input = form.actor_url.trim();
let public_url = state.public_url();
// Determine if input is a handle or URL
let actor_url =
if input.contains('@') && !input.starts_with("http://") && !input.starts_with("https://") {
// Handle format: @user@host or user@host
// Resolve via webfinger
let instance_actor = match crate::activitypub::actor::get_or_create_instance(db).await {
Ok(actor) => actor,
Err(e) => {
tracing::error!(error = %e, "Failed to get or create instance actor");
return HttpResponse::InternalServerError().body("Database error");
}
};
match crate::activitypub::remote::discover_by_handle(
db,
&state.http_client,
public_url,
&instance_actor,
&admin.name,
input,
)
.await
{
Ok(remote_actor) => remote_actor.actor_url,
Err(e) => {
tracing::error!(error = %e, handle = %input, "Failed to resolve handle");
return HttpResponse::BadRequest()
.body(format!("Failed to resolve handle: {}", e));
}
}
} else {
// URL format
input.to_string()
};
tracing::Span::current().record("actor_url", &actor_url);
// Refuse to block local actors
if actor_url.starts_with(public_url) {
return HttpResponse::BadRequest().body("Cannot block local actors");
}
// Check if the domain is blocked
let actor_domain = crate::moderation::normalize_domain(&actor_url).unwrap_or_default();
if let Ok(Some(domain_block)) = instance_blocks::Entity::find()
.filter(instance_blocks::Column::Domain.eq(actor_domain))
.one(db)
.await
{
tracing::info!(
admin = %admin.name,
actor_url = %actor_url,
blocked_domain = %domain_block.domain,
"Actor domain is already blocked"
);
return HttpResponse::BadRequest()
.body(format!("Domain {} is already blocked", domain_block.domain));
}
// Look up the remote actor
let remote_actor = match crate::entities::ap_remote_actors::Entity::find()
.filter(crate::entities::ap_remote_actors::Column::ActorUrl.eq(&actor_url))
.one(db)
.await
{
Ok(Some(actor)) => actor,
Ok(None) => {
return HttpResponse::NotFound().body("Remote actor not found");
}
Err(e) => {
tracing::error!(error = %e, "Failed to lookup remote actor");
return HttpResponse::InternalServerError().body("Database error");
}
};
// Check if already blocked
let existing = crate::entities::admin_remote_blocks::Entity::find()
.filter(crate::entities::admin_remote_blocks::Column::RemoteActorId.eq(remote_actor.id))
.one(db)
.await
.unwrap_or(None);
if existing.is_some() {
return HttpResponse::Found()
.append_header(("Location", "/admin/federation"))
.finish();
}
// Insert the block
let block = crate::entities::admin_remote_blocks::ActiveModel {
remote_actor_id: sea_orm::Set(remote_actor.id),
reason: sea_orm::Set(if form.reason.is_empty() {
None
} else {
Some(form.reason.clone())
}),
blocked_by: sea_orm::Set(admin.id),
..Default::default()
};
match block.insert(db).await {
Ok(_) => {
// Remove follow relationships (both directions)
if let Err(e) = remove_follow_relationships(db, remote_actor.id).await {
tracing::error!(error = %e, "Failed to remove follow relationships");
}
// Enqueue instance-actor-signed Block to their inbox
if let Err(e) = deliver_block_activity(&state, db, &remote_actor).await {
tracing::error!(error = %e, "Failed to deliver Block activity");
}
tracing::info!(
admin = %admin.name,
remote_actor_id = remote_actor.id,
"Remote user blocked"
);
HttpResponse::Found()
.append_header(("Location", "/admin/federation"))
.finish()
}
Err(e) => {
tracing::error!(error = %e, "Failed to block remote user");
HttpResponse::Found()
.append_header(("Location", "/admin/federation"))
.finish()
}
}
}
/// POST /admin/federation/unblock-remote-user - Unblock a remote user
#[post("/admin/federation/unblock-remote-user")]
#[instrument(skip(admin, data, form), fields(remote_actor_id))]
pub async fn unblock_remote_user(
admin: AdminUser,
data: web::Data<Arc<RwLock<AppState>>>,
form: web::Form<UnblockRemoteUserForm>,
) -> impl Responder {
let state = state_read(&data).await;
let db = &state.db;
let remote_actor_id: i64 = match form.remote_actor_id.parse() {
Ok(id) => id,
Err(_) => {
return HttpResponse::BadRequest().body("Invalid remote actor ID");
}
};
tracing::Span::current().record("remote_actor_id", remote_actor_id);
// TODO: In v1, federate Undo(Block) activity before deleting the row
match crate::entities::admin_remote_blocks::Entity::delete_many()
.filter(crate::entities::admin_remote_blocks::Column::RemoteActorId.eq(remote_actor_id))
.exec(db)
.await
{
Ok(result) => {
tracing::info!(
admin = %admin.name,
remote_actor_id = remote_actor_id,
rows_affected = result.rows_affected,
"Remote user unblocked"
);
}
Err(e) => {
tracing::error!(error = %e, "Failed to unblock remote user");
}
}
HttpResponse::Found()
.append_header(("Location", "/admin/federation"))
.finish()
}
/// Remove follow relationships involving a remote actor (both directions)
async fn remove_follow_relationships(
db: &sea_orm::DatabaseConnection,
remote_actor_id: i64,
) -> Result<(), sea_orm::DbErr> {
use crate::entities::{ap_followers, ap_following};
// Remove from ap_followers (remote follows local)
ap_followers::Entity::delete_many()
.filter(ap_followers::Column::RemoteActorId.eq(remote_actor_id))
.exec(db)
.await?;
// Remove from ap_following (local follows remote)
ap_following::Entity::delete_many()
.filter(ap_following::Column::RemoteActorId.eq(remote_actor_id))
.exec(db)
.await?;
Ok(())
}
/// Deliver a Block activity signed by the instance actor to the remote actor's
/// inbox
async fn deliver_block_activity(
state: &crate::AppState,
db: &sea_orm::DatabaseConnection,
remote_actor: &crate::entities::ap_remote_actors::Model,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Get or create the instance actor; it may not exist yet on a fresh
// instance, and blocking must still federate.
let instance_actor = crate::activitypub::actor::get_or_create_instance(db).await?;
let public_url = state.public_url();
let instance_actor_url = format!("{}/actor", public_url);
let block_id = format!("{}#block-{}", instance_actor_url, uuid::Uuid::new_v4());
let block_activity = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": block_id,
"type": "Block",
"actor": instance_actor_url,
"object": remote_actor.actor_url,
"to": [remote_actor.actor_url]
});
let activity_json = serde_json::to_string(&block_activity)?;
// Enqueue delivery to the remote actor's inbox
crate::activitypub::delivery::enqueue_delivery(
db,
instance_actor.id,
uuid::Uuid::new_v4(),
activity_json,
instance_actor_url,
remote_actor.inbox_url.clone(),
)
.await?;
Ok(())
}
/// Resolve object URLs to local notes or external links for the report detail
/// view.
async fn resolve_object_urls(
db: &sea_orm::DatabaseConnection,
object_urls: &[String],
public_url: &str,
) -> Vec<admin::ReportedObject> {
let notes_prefix = format!("{}/~/", public_url);
let mut result = Vec::with_capacity(object_urls.len());
for url in object_urls {
if let Some(note) = try_resolve_local_note(db, url, ¬es_prefix, public_url).await {
result.push(admin::ReportedObject::LocalNote(Box::new(note)));
} else {
result.push(admin::ReportedObject::ExternalLink(url.clone()));
}
}
result
}
/// Attempt to resolve a single object URL as a local note. Returns None if the
/// URL doesn't match the local note pattern or the note can't be found.
async fn try_resolve_local_note(
db: &sea_orm::DatabaseConnection,
url: &str,
notes_prefix: &str,
public_url: &str,
) -> Option<crate::views::Note> {
use sea_orm::{ColumnTrait, EntityTrait};
use url::Url;
use crate::entities::{ap_actors, ap_notes, users};
// URL must match {public_url}/~{user}/notes/{uuid}
let rest = url.strip_prefix(notes_prefix)?;
let (username, uuid_part) = rest.split_once("/notes/")?;
let note_uuid = uuid::Uuid::parse_str(uuid_part).ok()?;
let user = users::Entity::find()
.filter(users::Column::Name.eq(username))
.one(db)
.await
.ok()??;
let actor = ap_actors::Entity::find()
.filter(ap_actors::Column::UserId.eq(user.id))
.one(db)
.await
.ok()??;
let note = ap_notes::Entity::find()
.filter(ap_notes::Column::ActorId.eq(actor.id))
.filter(ap_notes::Column::Uuid.eq(note_uuid))
.one(db)
.await
.ok()??;
Some(crate::views::Note {
author: crate::views::NoteAuthor {
display_name: user.name.clone(),
handle: crate::views::NoteAuthorHandle::Local {
username: user.name.clone(),
actor_url: format!("/~{}", user.name),
},
icon_url: None,
},
content_html: note.content_html.clone(),
created_at: (¬e.created_at).into(),
updated_at: None,
permalink: Url::parse(url).unwrap_or_else(|_| {
Url::parse(&format!("{}/~{}/notes/{}", public_url, username, uuid_part)).unwrap()
}),
reactions: None,
actions: None,
attachments: Vec::new(),
base_url: format!("{}/~{}/notes", public_url, username),
uuid: uuid_part.to_string(),
})
}
/// Form data for blocking a remote user
#[derive(serde::Deserialize)]
pub struct BlockRemoteUserForm {
pub actor_url: String,
pub reason: String,
}
/// Form data for unblocking a remote user
#[derive(serde::Deserialize)]
pub struct UnblockRemoteUserForm {
pub remote_actor_id: String,
}
/// Form data for blocking a domain
#[derive(serde::Deserialize)]
pub struct BlockDomainForm {
pub domain: String,
pub reason: String,
}
/// Form data for unblocking a domain
#[derive(serde::Deserialize)]
pub struct UnblockDomainForm {
pub domain: String,
}