~ololduck/Frogue
fix(activitypub): respond 410 and negatively cache gone remote actors
Browse the tree at this commit
19 files changed, +637, −103
Cargo.lock+39−0
@@ -1872,6 +1872,8 @@ dependencies = [
"rand_core 0.10.1",
"ratatui",
"reqwest",
+ "reqwest-middleware",
+ "reqwest-tracing",
"rsa",
"russh",
"rust-embed",
@@ -2919,6 +2921,12 @@ dependencies = [
"regex-automata",
]
+[[package]]
+name = "matchit"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f926ade0c4e170215ae43342bf13b9310a437609c81f29f86c5df6657582ef9"
+
[[package]]
name = "maud"
version = "0.27.0"
@@ -4081,6 +4089,37 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "reqwest-middleware"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "http 1.5.0",
+ "reqwest",
+ "serde",
+ "thiserror",
+ "tower-service",
+]
+
+[[package]]
+name = "reqwest-tracing"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e5af0cd6fc3d3c8f703d597af70b6e4e62432c63157b49419fa1ffaf481702"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "getrandom 0.2.17",
+ "http 1.5.0",
+ "matchit",
+ "reqwest",
+ "reqwest-middleware",
+ "tracing",
+]
+
[[package]]
name = "rfc6979"
version = "0.6.0"
frogue/Cargo.toml+4−0
@@ -115,6 +115,10 @@ url = "2"
# HTTP client for fetching remote ActivityPub actors when verifying signatures
# and for delivering activities to followers' inboxes.
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] }
+# Middleware support for reqwest, used for automatic tracing spans on HTTP requests.
+reqwest-middleware = { version = "0.5", features = ["rustls", "json"] }
+# Automatic tracing spans for reqwest requests.
+reqwest-tracing = "0.7"
# Parsing and formatting HTTP Date headers for HTTP Signatures.
httpdate = "1"
frogue/src/activitypub/delivery.rs+6−4
@@ -28,9 +28,10 @@ pub fn spawn(state: Arc<RwLock<AppState>>) -> tokio::task::JoinHandle<()> {
let state_guard = state.read().await;
let db = state_guard.db.clone();
let public_url = state_guard.public_url().to_string();
+ let http_client = state_guard.http_client().clone();
drop(state_guard);
− if let Err(e) = process_pending_deliveries(&db, &public_url).await {
+ if let Err(e) = process_pending_deliveries(&db, &public_url, &http_client).await {
error!(error = %e, "delivery worker error");
}
}
@@ -81,6 +82,7 @@ pub async fn enqueue_delivery(
async fn process_pending_deliveries(
db: &DatabaseConnection,
public_url: &str,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Find pending deliveries where next_attempt_at <= now
let now = Utc::now();
@@ -93,7 +95,7 @@ async fn process_pending_deliveries(
.await?;
for delivery in deliveries {
− if let Err(e) = process_delivery(db, &delivery, public_url).await {
+ if let Err(e) = process_delivery(db, &delivery, public_url, http_client).await {
error!(delivery_id = delivery.id, error = %e, "failed to process delivery");
}
}
@@ -105,6 +107,7 @@ async fn process_delivery(
db: &DatabaseConnection,
delivery: &ap_deliveries::Model,
public_url: &str,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let activity = ApActivities::find_by_id(delivery.activity_id)
.one(db)
@@ -147,8 +150,7 @@ async fn process_delivery(
.map_err(|e| format!("signing failed: {}", e))?;
// Send the request
− let client = reqwest::Client::new();
− let result = client
+ let result = http_client
.post(inbox_url)
.header("Content-Type", "application/activity+json")
.header("Date", &date)
frogue/src/activitypub/forge/inbound.rs+20−8
@@ -18,9 +18,10 @@ use crate::entities::{ap_remote_actors, tickets as tickets_entity};
///
/// Creates a local ticket from the embedded Ticket object and sends Accept.
/// Per decision 7, Offer with inline Patch is rejected.
−#[instrument(skip(db, offer))]
+#[instrument(skip(db, http_client, offer))]
pub async fn handle_offer(
db: &DatabaseConnection,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
public_url: &str,
repo_actor: &crate::entities::ap_actors::Model,
verified_actor_url: &str,
@@ -43,7 +44,9 @@ pub async fn handle_offer(
Ok(Some(actor)) => actor,
Ok(None) => {
// Not in cache, try to fetch
− match crate::activitypub::remote::resolve_actor(db, verified_actor_url).await {
+ match crate::activitypub::remote::resolve_actor(db, http_client, verified_actor_url)
+ .await
+ {
Ok(actor) => actor,
Err(e) => {
error!(error = %e, actor_url = %verified_actor_url, "failed to resolve remote actor");
@@ -239,9 +242,10 @@ pub async fn handle_offer(
/// Handle an incoming Resolve activity (ticket closure).
///
/// Closes the referenced ticket if the remote actor is authorized.
−#[instrument(skip(db, resolve))]
+#[instrument(skip(db, http_client, resolve))]
pub async fn handle_resolve(
db: &DatabaseConnection,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
public_url: &str,
repo_actor: &crate::entities::ap_actors::Model,
verified_actor_url: &str,
@@ -264,7 +268,9 @@ pub async fn handle_resolve(
Ok(Some(actor)) => actor,
Ok(None) => {
// Not in cache, try to fetch
− match crate::activitypub::remote::resolve_actor(db, verified_actor_url).await {
+ match crate::activitypub::remote::resolve_actor(db, http_client, verified_actor_url)
+ .await
+ {
Ok(actor) => actor,
Err(e) => {
error!(error = %e, actor_url = %verified_actor_url, "failed to resolve remote actor");
@@ -350,9 +356,10 @@ pub async fn handle_resolve(
/// Handle an incoming Edit activity (ticket update).
///
/// Updates the referenced ticket if the remote actor is authorized.
−#[instrument(skip(db, edit))]
+#[instrument(skip(db, http_client, edit))]
pub async fn handle_edit(
db: &DatabaseConnection,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
public_url: &str,
repo_actor: &crate::entities::ap_actors::Model,
verified_actor_url: &str,
@@ -375,7 +382,9 @@ pub async fn handle_edit(
Ok(Some(actor)) => actor,
Ok(None) => {
// Not in cache, try to fetch
− match crate::activitypub::remote::resolve_actor(db, verified_actor_url).await {
+ match crate::activitypub::remote::resolve_actor(db, http_client, verified_actor_url)
+ .await
+ {
Ok(actor) => actor,
Err(e) => {
error!(error = %e, actor_url = %verified_actor_url, "failed to resolve remote actor");
@@ -479,9 +488,10 @@ pub async fn handle_edit(
/// Handle an incoming Create activity for comments.
///
/// Creates a comment on a ticket if the inReplyTo references a local ticket.
−#[instrument(skip(db, create))]
+#[instrument(skip(db, http_client, create))]
pub async fn handle_create(
db: &DatabaseConnection,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
public_url: &str,
repo_actor: &crate::entities::ap_actors::Model,
verified_actor_url: &str,
@@ -505,7 +515,9 @@ pub async fn handle_create(
Ok(Some(actor)) => actor,
Ok(None) => {
// Not in cache, try to fetch
− match crate::activitypub::remote::resolve_actor(db, verified_actor_url).await {
+ match crate::activitypub::remote::resolve_actor(db, http_client, verified_actor_url)
+ .await
+ {
Ok(actor) => actor,
Err(e) => {
error!(error = %e, actor_url = %verified_actor_url, "failed to resolve remote actor");
frogue/src/activitypub/inbox.rs+42−9
@@ -24,14 +24,23 @@ use crate::entities::{ap_followers, ap_remote_actors, ap_remote_notes};
/// appropriate handler based on activity type.
pub async fn handle(
db: &DatabaseConnection,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
public_url: &str,
signer: &crate::entities::ap_actors::Model,
req: &HttpRequest,
body: &[u8],
) -> actix_web::HttpResponse {
// Verify HTTP signature
− let verified_actor_url = match verify_inbox_request(db, req, body).await {
+ let verified_actor_url = match verify_inbox_request(db, http_client, req, body).await {
Ok(actor_url) => actor_url,
+ Err(signatures::SignatureError::ActorGone(key_id)) => {
+ info!(
+ key_id = %key_id,
+ path = %req.uri().path(),
+ "actor is gone (410), rejecting delivery"
+ );
+ return actix_web::HttpResponse::Gone().finish();
+ }
Err(e) => {
warn!(error = %e, path = %req.uri().path(), "inbox signature verification failed");
error!(
@@ -87,7 +96,7 @@ pub async fn handle(
// Process the activity
match activity {
InboxActivity::Follow(follow) => {
− handle_follow(db, public_url, &verified_actor_url, &follow).await
+ handle_follow(db, http_client, public_url, &verified_actor_url, &follow).await
}
InboxActivity::Undo(undo) => handle_undo(db, &verified_actor_url, &undo).await,
InboxActivity::Create(create) => {
@@ -95,6 +104,7 @@ pub async fn handle(
if signer.repo_id.is_some() {
return crate::activitypub::forge::inbound::handle_create(
db,
+ http_client,
public_url,
signer,
&verified_actor_url,
@@ -123,6 +133,7 @@ pub async fn handle(
match crate::activitypub::remote::notes::handle_create(
db,
+ http_client,
&verified_actor_url,
public_url,
&user_name,
@@ -159,6 +170,7 @@ pub async fn handle(
match crate::activitypub::remote::notes::handle_update(
db,
+ http_client,
&verified_actor_url,
public_url,
&user_name,
@@ -187,7 +199,16 @@ pub async fn handle(
}
// Phase 5: Reactions
InboxActivity::Like(like) => {
− handle_reaction(db, public_url, &verified_actor_url, &like, "like", None).await
+ handle_reaction(
+ db,
+ http_client,
+ public_url,
+ &verified_actor_url,
+ &like,
+ "like",
+ None,
+ )
+ .await
}
InboxActivity::Announce(announce) => {
// Check if the Announce contains an embedded Note from an accepted followee
@@ -251,6 +272,7 @@ pub async fn handle(
// Not a Note, treat as reaction on local note
handle_reaction(
db,
+ http_client,
public_url,
&verified_actor_url,
&ReactionActivity {
@@ -268,6 +290,7 @@ pub async fn handle(
// URL-only Announce, treat as reaction on local note
handle_reaction(
db,
+ http_client,
public_url,
&verified_actor_url,
&ReactionActivity {
@@ -285,6 +308,7 @@ pub async fn handle(
InboxActivity::EmojiReact(react) => {
handle_reaction(
db,
+ http_client,
public_url,
&verified_actor_url,
&react,
@@ -384,6 +408,7 @@ pub async fn handle(
crate::activitypub::forge::inbound::handle_offer(
db,
+ http_client,
public_url,
signer,
&verified_actor_url,
@@ -400,6 +425,7 @@ pub async fn handle(
crate::activitypub::forge::inbound::handle_resolve(
db,
+ http_client,
public_url,
signer,
&verified_actor_url,
@@ -416,6 +442,7 @@ pub async fn handle(
crate::activitypub::forge::inbound::handle_edit(
db,
+ http_client,
public_url,
signer,
&verified_actor_url,
@@ -449,6 +476,7 @@ pub async fn handle(
/// Handle a reaction (Like, Announce, EmojiReact) on a local note.
async fn handle_reaction(
db: &DatabaseConnection,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
public_url: &str,
actor_url: &str,
reaction: &crate::activitypub::types::ReactionActivity,
@@ -506,7 +534,7 @@ async fn handle_reaction(
};
// Resolve the remote actor
− let remote_actor = match remote::resolve_actor(db, actor_url).await {
+ let remote_actor = match remote::resolve_actor(db, http_client, actor_url).await {
Ok(actor) => actor,
Err(e) => {
error!(error = %e, actor_url, "failed to resolve remote actor");
@@ -566,6 +594,7 @@ fn extract_user_from_url(note_url: &str, base: &str) -> String {
/// Verify the HTTP signature on an inbox request.
async fn verify_inbox_request(
db: &DatabaseConnection,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
req: &HttpRequest,
body: &[u8],
) -> Result<String, SignatureError> {
@@ -588,12 +617,15 @@ async fn verify_inbox_request(
req.headers(),
body,
|key_id| async move {
− match remote::resolve_key(db, &key_id).await {
− Ok(Some((actor_url, public_key_pem))) => Some((actor_url, public_key_pem)),
− Ok(None) => None,
+ match remote::resolve_key(db, http_client, &key_id).await {
+ Ok(Some((actor_url, public_key_pem))) => Ok(Some((actor_url, public_key_pem))),
+ Ok(None) => Ok(None),
+ Err(remote::ResolveError::Gone) => {
+ Err(signatures::SignatureError::ActorGone(key_id.clone()))
+ }
Err(e) => {
error!(key_id = %key_id, error = %e, "failed to resolve key");
− None
+ Ok(None)
}
}
},
@@ -604,6 +636,7 @@ async fn verify_inbox_request(
/// Handle a Follow activity.
async fn handle_follow(
db: &DatabaseConnection,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
public_url: &str,
actor_url: &str,
follow: &crate::activitypub::types::FollowActivity,
@@ -638,7 +671,7 @@ async fn handle_follow(
tracing::info!(local_actor_id = local_actor.id, "found local actor");
// Resolve or fetch the remote actor
− let remote_actor = match remote::resolve_actor(db, actor_url).await {
+ let remote_actor = match remote::resolve_actor(db, http_client, actor_url).await {
Ok(actor) => actor,
Err(e) => {
tracing::error!(error = %e, actor_url, "failed to resolve remote actor");
frogue/src/activitypub/remote/mod.rs+81−18
@@ -11,7 +11,6 @@ pub mod notes;
use std::net::{IpAddr, Ipv4Addr};
−use reqwest::Client;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use serde::Deserialize;
use tracing::{debug, error, info};
@@ -113,9 +112,10 @@ async fn check_ssrf(hostname: &str) -> Result<(), ResolveError> {
/// Accepts:
/// - @user@host or user@host (webfinger lookup)
/// - https://example.com/users/alice (direct URL)
−#[tracing::instrument(skip(db, signer), fields(handle = %handle))]
+#[tracing::instrument(skip(db, signer, http_client), fields(handle = %handle))]
pub async fn discover_by_handle(
db: &DatabaseConnection,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
public_url: &str,
signer: &crate::entities::ap_actors::Model,
user_name: &str,
@@ -158,16 +158,20 @@ pub async fn discover_by_handle(
check_ssrf(host).await?;
// Perform webfinger lookup
− webfinger_lookup(host, username).await?
+ webfinger_lookup(http_client, host, username).await?
};
// Fetch and cache the actor document with signed GET
− fetch_actor_signed(db, &actor_url, public_url, signer, user_name).await
+ fetch_actor_signed(db, http_client, &actor_url, public_url, signer, user_name).await
}
/// Perform webfinger lookup to find actor URL
−#[tracing::instrument(skip(), fields(host = %host, username = %username))]
−async fn webfinger_lookup(host: &str, username: &str) -> Result<String, ResolveError> {
+#[tracing::instrument(skip(http_client), fields(host = %host, username = %username))]
+async fn webfinger_lookup(
+ http_client: &reqwest_middleware::ClientWithMiddleware,
+ host: &str,
+ username: &str,
+) -> Result<String, ResolveError> {
// Use http:// for dev/test (when private hosts are allowed), https:// for production
let scheme = if std::env::var(FROGUE_ALLOW_PRIVATE_HOSTS_VAR_KEY).is_ok() {
"http"
@@ -182,8 +186,7 @@ async fn webfinger_lookup(host: &str, username: &str) -> Result<String, ResolveE
info!(url = %webfinger_url, "performing webfinger lookup");
− let client = Client::new();
− let response = client
+ let response = http_client
.get(&webfinger_url)
.header("Accept", "application/jrd+json")
.send()
@@ -239,6 +242,7 @@ async fn webfinger_lookup(host: &str, username: &str) -> Result<String, ResolveE
/// This is a generic helper for fetching ActivityPub objects with proper signing.
/// Used for both actor documents and note objects.
pub async fn signed_get(
+ http_client: &reqwest_middleware::ClientWithMiddleware,
url_str: &str,
public_url: &str,
signer: &crate::entities::ap_actors::Model,
@@ -275,8 +279,7 @@ pub async fn signed_get(
info!(url = %url_str, "fetching with signed GET");
− let client = Client::new();
− let response = client
+ let response = http_client
.get(url_str)
.header("Accept", "application/activity+json")
.header("Date", &date)
@@ -295,6 +298,7 @@ pub async fn signed_get(
/// Fetch a remote actor document with HTTP signature
async fn fetch_actor_signed(
db: &DatabaseConnection,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
actor_url: &str,
public_url: &str,
signer: &crate::entities::ap_actors::Model,
@@ -302,7 +306,7 @@ async fn fetch_actor_signed(
) -> Result<ap_remote_actors::Model, ResolveError> {
info!(actor_url = %actor_url, "fetching remote actor with signed GET");
− let json = signed_get(actor_url, public_url, signer, user_name).await?;
+ let json = signed_get(http_client, actor_url, public_url, signer, user_name).await?;
let remote_actor: RemoteActor = serde_json::from_value(json)
.map_err(|e| ResolveError::InvalidHandle(format!("Invalid actor document: {}", e)))?;
@@ -359,9 +363,10 @@ async fn fetch_actor_signed(
/// First checks the cache. If not found or if the key_id doesn't match,
/// fetches the actor document from the remote server.
/// If the cache entry is older than 24 hours, re-fetches and updates it in place.
−#[tracing::instrument(skip(db), fields(actor_url = %actor_url))]
+#[tracing::instrument(skip(db, http_client), fields(actor_url = %actor_url))]
pub async fn resolve_actor(
db: &DatabaseConnection,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
actor_url: &str,
) -> Result<ap_remote_actors::Model, ResolveError> {
// Check cache first
@@ -382,17 +387,63 @@ pub async fn resolve_actor(
info!(actor_url, "cache stale, re-fetching remote actor");
}
+ // Check tombstone cache (negative cache for gone actors)
+ if let Some(tombstone) = crate::entities::ap_remote_actor_tombstones::Entity::find()
+ .filter(crate::entities::ap_remote_actor_tombstones::Column::ActorUrl.eq(actor_url))
+ .one(db)
+ .await?
+ {
+ let now = chrono::Utc::now();
+ let age = now.signed_duration_since(tombstone.gone_at);
+ // Tombstones are valid for 30 days
+ if age.num_days() < 30 {
+ info!(actor_url, "actor is in tombstone cache (gone)");
+ return Err(ResolveError::Gone);
+ }
+ // Tombstone is stale, will attempt re-fetch
+ info!(actor_url, "tombstone stale, attempting re-fetch");
+ }
+
// Fetch from remote
info!(actor_url, "fetching remote actor");
− let client = Client::new();
− let response = client
+ let response = http_client
.get(actor_url)
.header("Accept", "application/activity+json")
.send()
.await?;
− if !response.status().is_success() {
− return Err(ResolveError::FetchFailed(response.status().as_u16()));
+ let status = response.status();
+ if status == reqwest::StatusCode::GONE {
+ info!(actor_url, "remote actor is gone (410), creating tombstone");
+
+ // Create or update tombstone
+ let now = chrono::Utc::now();
+ if let Some(existing_tombstone) =
+ crate::entities::ap_remote_actor_tombstones::Entity::find()
+ .filter(crate::entities::ap_remote_actor_tombstones::Column::ActorUrl.eq(actor_url))
+ .one(db)
+ .await?
+ {
+ // Update existing tombstone
+ let mut active: crate::entities::ap_remote_actor_tombstones::ActiveModel =
+ existing_tombstone.into();
+ active.gone_at = Set(now.into());
+ active.update(db).await?;
+ } else {
+ // Insert new tombstone
+ let tombstone = crate::entities::ap_remote_actor_tombstones::ActiveModel {
+ actor_url: Set(actor_url.to_string()),
+ gone_at: Set(now.into()),
+ ..Default::default()
+ };
+ tombstone.insert(db).await?;
+ }
+
+ return Err(ResolveError::Gone);
+ }
+
+ if !status.is_success() {
+ return Err(ResolveError::FetchFailed(status.as_u16()));
}
let remote_actor: RemoteActor = response.json().await?;
@@ -408,6 +459,12 @@ pub async fn resolve_actor(
let inbox_url = remote_actor.inbox.ok_or(ResolveError::MissingInbox)?;
let shared_inbox_url = remote_actor.endpoints.and_then(|e| e.shared_inbox);
+ // Actor is back! Remove any tombstone
+ crate::entities::ap_remote_actor_tombstones::Entity::delete_many()
+ .filter(crate::entities::ap_remote_actor_tombstones::Column::ActorUrl.eq(actor_url))
+ .exec(db)
+ .await?;
+
// Check if we already have this actor cached
if let Some(existing) = ApRemoteActors::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(actor_url))
@@ -451,6 +508,7 @@ pub async fn resolve_actor(
/// We strip the fragment, resolve the actor, and verify the key ID matches.
pub async fn resolve_key(
db: &DatabaseConnection,
+ http_client: &reqwest_middleware::ClientWithMiddleware,
key_id: &str,
) -> Result<Option<(String, String)>, ResolveError> {
// Strip fragment from keyId to get actor URL
@@ -459,9 +517,10 @@ pub async fn resolve_key(
None => key_id,
};
− let actor = match resolve_actor(db, actor_url).await {
+ let actor = match resolve_actor(db, http_client, actor_url).await {
Ok(actor) => actor,
Err(ResolveError::NotFound) => return Ok(None),
+ Err(ResolveError::Gone) => return Err(ResolveError::Gone),
Err(e) => return Err(e),
};
@@ -483,9 +542,13 @@ pub enum ResolveError {
#[error("database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("HTTP error: {0}")]
− Http(#[from] reqwest::Error),
+ Http(#[from] reqwest_middleware::Error),
+ #[error("HTTP error: {0}")]
+ Reqwest(#[from] reqwest::Error),
#[error("actor not found")]
NotFound,
+ #[error("actor is gone")]
+ Gone,
#[error("fetch failed with status {0}")]
FetchFailed(u16),
#[error("actor ID mismatch: requested {requested}, received {received}")]
frogue/src/activitypub/remote/notes.rs+34−22
@@ -95,9 +95,10 @@ pub async fn store_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))]
+#[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,
@@ -109,16 +110,21 @@ pub async fn handle_create(
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));
− }
− };
+ 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) {
@@ -177,9 +183,10 @@ pub async fn handle_create(
/// 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))]
+#[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,
@@ -191,16 +198,21 @@ pub async fn handle_update(
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
− }
− };
+ 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) {
frogue/src/activitypub/signatures/mod.rs+5−3
@@ -33,6 +33,8 @@ pub enum SignatureError {
KeyNotFound(String),
#[error("invalid key id: {0}")]
InvalidKeyId(String),
+ #[error("actor is gone: {0}")]
+ ActorGone(String),
}
/// Build the Signature header value for an outbound POST.
@@ -123,7 +125,7 @@ pub async fn verify_request<F, Fut>(
) -> Result<String, SignatureError>
where
F: FnOnce(String) -> Fut,
− Fut: std::future::Future<Output = Option<(String, String)>>,
+ Fut: std::future::Future<Output = Result<Option<(String, String)>, SignatureError>>,
{
// Check if this is an RFC 9421 signature (has signature-input header)
if headers.get("signature-input").is_some() {
@@ -150,7 +152,7 @@ async fn verify_request_legacy<F, Fut>(
) -> Result<String, SignatureError>
where
F: FnOnce(String) -> Fut,
− Fut: std::future::Future<Output = Option<(String, String)>>,
+ Fut: std::future::Future<Output = Result<Option<(String, String)>, SignatureError>>,
{
// Reconstruct path_and_query for legacy signatures
let path_and_query = if let Some(q) = query {
@@ -250,7 +252,7 @@ where
// Fetch the public key
tracing::debug!(key_id = %key_id, "fetching public key");
let (actor_url, public_key_pem) = fetch_key(key_id.clone())
− .await
+ .await?
.ok_or_else(|| SignatureError::KeyNotFound(key_id.clone()))?;
tracing::debug!(actor_url = %actor_url, "public key fetched successfully");
frogue/src/activitypub/signatures/rfc9421.rs+13−13
@@ -52,7 +52,7 @@ pub async fn verify_request<F, Fut>(
) -> Result<String, SignatureError>
where
F: FnOnce(String) -> Fut,
− Fut: std::future::Future<Output = Option<(String, String)>>,
+ Fut: std::future::Future<Output = Result<Option<(String, String)>, SignatureError>>,
{
// 1. Parse Signature-Input header (RFC 8941 dictionary)
let signature_input = headers
@@ -220,7 +220,7 @@ where
// 11. Fetch the public key
tracing::debug!(key_id = %key_id, "fetching public key for RFC 9421 verification");
let (actor_url, public_key_pem) = fetch_key(key_id.clone())
− .await
+ .await?
.ok_or_else(|| SignatureError::KeyNotFound(key_id.clone()))?;
tracing::debug!(actor_url = %actor_url, "public key fetched successfully");
@@ -375,9 +375,9 @@ mod tests {
body,
|kid| async move {
if kid == key_id {
− Some((key_id.to_string(), public_pem))
+ Ok(Some((key_id.to_string(), public_pem)))
} else {
− None
+ Ok(None)
}
},
)
@@ -440,9 +440,9 @@ mod tests {
tampered_body,
|kid| async move {
if kid == key_id {
− Some((key_id.to_string(), public_pem))
+ Ok(Some((key_id.to_string(), public_pem)))
} else {
− None
+ Ok(None)
}
},
)
@@ -503,7 +503,7 @@ mod tests {
method,
&headers,
body,
− |_kid| async move { None },
+ |_kid| async move { Ok(None) },
)
.await;
@@ -565,9 +565,9 @@ mod tests {
body,
|kid| async move {
if kid == key_id {
− Some((key_id.to_string(), public_pem))
+ Ok(Some((key_id.to_string(), public_pem)))
} else {
− None
+ Ok(None)
}
},
)
@@ -631,9 +631,9 @@ mod tests {
body,
|kid| async move {
if kid == key_id {
− Some((key_id.to_string(), public_pem))
+ Ok(Some((key_id.to_string(), public_pem)))
} else {
− None
+ Ok(None)
}
},
)
@@ -697,9 +697,9 @@ mod tests {
body,
|kid| async move {
if kid == key_id {
− Some((key_id.to_string(), public_pem))
+ Ok(Some((key_id.to_string(), public_pem)))
} else {
− None
+ Ok(None)
}
},
)
frogue/src/entities/ap_remote_actor_tombstones.rs+22−0
@@ -0,0 +1,22 @@
+//! Entity representing a tombstone for a deleted remote ActivityPub actor.
+//!
+//! When we try to fetch a remote actor and get a 410 Gone response, we record
+//! it here so we don't repeatedly try to fetch it. This prevents retry storms
+//! from servers that keep sending activities for deleted accounts.
+
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "ap_remote_actor_tombstones")]
+pub struct Model {
+ #[sea_orm(primary_key)]
+ pub id: i64,
+ #[sea_orm(unique)]
+ pub actor_url: String,
+ pub gone_at: DateTimeWithTimeZone,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {}
+
+impl ActiveModelBehavior for ActiveModel {}
frogue/src/entities/mod.rs+1−0
@@ -9,6 +9,7 @@ pub mod ap_followers;
pub mod ap_following;
pub mod ap_notes;
pub mod ap_reactions;
+pub mod ap_remote_actor_tombstones;
pub mod ap_remote_actors;
pub mod ap_remote_notes;
pub mod ap_remote_tickets;
frogue/src/entities/prelude.rs+1−0
@@ -7,6 +7,7 @@ pub use super::ap_followers::Entity as ApFollowers;
pub use super::ap_following::Entity as ApFollowing;
pub use super::ap_notes::Entity as ApNotes;
pub use super::ap_reactions::Entity as ApReactions;
+pub use super::ap_remote_actor_tombstones::Entity as ApRemoteActorTombstones;
pub use super::ap_remote_actors::Entity as ApRemoteActors;
pub use super::ap_remote_notes::Entity as ApRemoteNotes;
pub use super::ap_remote_tickets::Entity as ApRemoteTickets;
frogue/src/lib.rs+10−0
@@ -108,6 +108,8 @@ pub struct AppState {
ssh: ssh::Settings,
/// Canonical public URL for generating links in git hook messages.
public_url: String,
+ /// HTTP client with tracing middleware for outbound ActivityPub requests.
+ http_client: reqwest_middleware::ClientWithMiddleware,
}
impl AppState {
@@ -115,6 +117,11 @@ impl AppState {
pub(crate) fn public_url(&self) -> &str {
&self.public_url
}
+
+ /// The HTTP client with tracing middleware for outbound requests.
+ pub(crate) fn http_client(&self) -> &reqwest_middleware::ClientWithMiddleware {
+ &self.http_client
+ }
}
/// Waits for a read of the application state.
@@ -350,6 +357,9 @@ pub async fn run() -> miette::Result<()> {
repo_root,
ssh: ssh::settings(ssh_bind_addr),
public_url,
+ http_client: reqwest_middleware::ClientBuilder::new(reqwest::Client::new())
+ .with(reqwest_tracing::TracingMiddleware::default())
+ .build(),
}));
ssh::serve(ssh_listener, ssh_config, app_state.clone(), ssh_throttle)?;
frogue/src/webui/federation.rs+19−2
@@ -170,7 +170,15 @@ pub async fn post_inbox(
}
};
− activitypub::inbox::handle(&state.db, state.public_url(), &actor, &req, &body).await
+ activitypub::inbox::handle(
+ &state.db,
+ state.http_client(),
+ state.public_url(),
+ &actor,
+ &req,
+ &body,
+ )
+ .await
}
/// GET /~{user}/followers
@@ -1293,6 +1301,7 @@ pub async fn post_follow(
// Perform discovery
match activitypub::remote::discover_by_handle(
&state.db,
+ state.http_client(),
state.public_url(),
&actor,
&user.name,
@@ -1657,7 +1666,15 @@ pub async fn post_repo_inbox(
}
};
− activitypub::inbox::handle(&state.db, state.public_url(), &actor, &req, &body).await
+ activitypub::inbox::handle(
+ &state.db,
+ state.http_client(),
+ state.public_url(),
+ &actor,
+ &req,
+ &body,
+ )
+ .await
}
/// GET /~{user_or_team}/{repository_slug}/followers
frogue/src/webui/remote_forge.rs+30−16
@@ -232,14 +232,21 @@ pub async fn post_remote_ticket(
};
// Fetch the remote repository actor document
− let repo_doc =
− match remote::signed_get(target_repo_url, public_url, &actor, ¤t.name).await {
− Ok(doc) => doc,
− Err(e) => {
− error!(error = %e, url = %target_repo_url, "failed to fetch remote repository");
− return show_form_error(&username, "Failed to fetch remote repository");
− }
− };
+ let repo_doc = match remote::signed_get(
+ state.http_client(),
+ target_repo_url,
+ public_url,
+ &actor,
+ ¤t.name,
+ )
+ .await
+ {
+ Ok(doc) => doc,
+ Err(e) => {
+ error!(error = %e, url = %target_repo_url, "failed to fetch remote repository");
+ return show_form_error(&username, "Failed to fetch remote repository");
+ }
+ };
// Validate it's a Repository
if repo_doc.get("type").and_then(|t| t.as_str()) != Some("Repository") {
@@ -272,14 +279,21 @@ pub async fn post_remote_ticket(
};
// Fetch the tracker actor document to get its inbox
− let tracker_doc =
− match remote::signed_get(&tracker_url, public_url, &actor, ¤t.name).await {
− Ok(doc) => doc,
− Err(e) => {
− error!(error = %e, url = %tracker_url, "failed to fetch tracker");
− return show_form_error(&username, "Failed to fetch tracker");
− }
− };
+ let tracker_doc = match remote::signed_get(
+ state.http_client(),
+ &tracker_url,
+ public_url,
+ &actor,
+ ¤t.name,
+ )
+ .await
+ {
+ Ok(doc) => doc,
+ Err(e) => {
+ error!(error = %e, url = %tracker_url, "failed to fetch tracker");
+ return show_form_error(&username, "Failed to fetch tracker");
+ }
+ };
// Verify the bidirectional reference: tracker must list the repo in tracksTicketsFor/tracksPatchesFor
// Exception: be lenient when tracker == repo actor (self-tracking case)
frogue/tests/gone_actor.rs+187−0
@@ -0,0 +1,187 @@
+//! Integration tests for handling deleted remote actors that return 410 Gone.
+
+mod support;
+
+use std::time::Duration;
+
+use support::ap::RemoteActor;
+use support::{Client, Server, free_port};
+
+/// The password the tests register and log in with.
+const PASSWORD: &str = "correct horse battery staple";
+
+/// Register a user through the web form.
+fn register(client: &mut Client, name: &str) {
+ let email = format!("{name}@example.org");
+ let response = client.post_form(
+ "/register",
+ &[("name", name), ("email", &email), ("password", PASSWORD)],
+ );
+ assert_eq!(
+ response.status, 303,
+ "registering should log in\n{}",
+ response.body
+ );
+}
+
+/// Test: Inbox responds with 410 when the signer's actor fetch returns 410 Gone
+#[tokio::test]
+async fn inbox_returns_410_for_gone_signer() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut alice_client = Client::new(port);
+ register(&mut alice_client, "alice");
+
+ // Start a remote actor that returns 410 Gone for actor fetches
+ let recorder_port = free_port();
+ let recorder = RemoteActor::serve_gone(recorder_port);
+
+ // Send a signed activity from the gone actor
+ let activity_json = serde_json::json!({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": format!("{}/activity/1", recorder.actor_url),
+ "type": "Create",
+ "actor": recorder.actor_url,
+ "object": {
+ "type": "Note",
+ "content": "Hello from a deleted account"
+ }
+ });
+ let body = serde_json::to_string(&activity_json).unwrap();
+
+ let (signature, signature_input, date, content_digest) = support::ap::signed_post_rfc9421(
+ &recorder.private_key_pem,
+ &recorder.key_id,
+ &format!("127.0.0.1:{port}"),
+ "/~alice/inbox",
+ &body,
+ );
+
+ let response = alice_client.post_json_with_headers(
+ "/~alice/inbox",
+ &body,
+ &[
+ ("Signature", &signature),
+ ("Signature-Input", &signature_input),
+ ("Date", &date),
+ ("Content-Digest", &content_digest),
+ ("Content-Type", "application/activity+json"),
+ ],
+ );
+
+ assert_eq!(
+ response.status, 410,
+ "Inbox should return 410 Gone when signer's actor fetch returns 410"
+ );
+
+ Ok(())
+}
+
+/// Test: Tombstone caching - second request doesn't re-fetch the gone actor
+#[tokio::test]
+async fn tombstone_prevents_refetch() -> miette::Result<()> {
+ let port = free_port();
+ let mut server = Server::spawn_federated(&format!("127.0.0.1:{port}"));
+ server.wait_for_connection(port);
+
+ let mut alice_client = Client::new(port);
+ register(&mut alice_client, "alice");
+
+ // Start a remote actor that returns 410 Gone
+ let recorder_port = free_port();
+ let recorder = RemoteActor::serve_gone(recorder_port);
+
+ // First request - triggers the fetch and creates tombstone
+ let activity_json = serde_json::json!({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": format!("{}/activity/1", recorder.actor_url),
+ "type": "Create",
+ "actor": recorder.actor_url,
+ "object": {
+ "type": "Note",
+ "content": "First request"
+ }
+ });
+ let body = serde_json::to_string(&activity_json).unwrap();
+
+ let (signature, signature_input, date, content_digest) = support::ap::signed_post_rfc9421(
+ &recorder.private_key_pem,
+ &recorder.key_id,
+ &format!("127.0.0.1:{port}"),
+ "/~alice/inbox",
+ &body,
+ );
+
+ let response1 = alice_client.post_json_with_headers(
+ "/~alice/inbox",
+ &body,
+ &[
+ ("Signature", &signature),
+ ("Signature-Input", &signature_input),
+ ("Date", &date),
+ ("Content-Digest", &content_digest),
+ ("Content-Type", "application/activity+json"),
+ ],
+ );
+
+ assert_eq!(response1.status, 410, "First request should return 410");
+
+ // Wait a bit for the fetch to complete
+ std::thread::sleep(Duration::from_millis(100));
+
+ // Count GET requests after first request
+ let get_count_after_first = recorder.recorded_get_requests().len();
+
+ // Second request - should use tombstone, not re-fetch
+ let activity_json2 = serde_json::json!({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": format!("{}/activity/2", recorder.actor_url),
+ "type": "Create",
+ "actor": recorder.actor_url,
+ "object": {
+ "type": "Note",
+ "content": "Second request"
+ }
+ });
+ let body2 = serde_json::to_string(&activity_json2).unwrap();
+
+ let (signature2, signature_input2, date2, content_digest2) = support::ap::signed_post_rfc9421(
+ &recorder.private_key_pem,
+ &recorder.key_id,
+ &format!("127.0.0.1:{port}"),
+ "/~alice/inbox",
+ &body2,
+ );
+
+ let response2 = alice_client.post_json_with_headers(
+ "/~alice/inbox",
+ &body2,
+ &[
+ ("Signature", &signature2),
+ ("Signature-Input", &signature_input2),
+ ("Date", &date2),
+ ("Content-Digest", &content_digest2),
+ ("Content-Type", "application/activity+json"),
+ ],
+ );
+
+ assert_eq!(
+ response2.status, 410,
+ "Second request should also return 410"
+ );
+
+ // Wait a bit
+ std::thread::sleep(Duration::from_millis(100));
+
+ // Verify no additional GET requests were made (tombstone was used)
+ let get_count_after_second = recorder.recorded_get_requests().len();
+ assert_eq!(
+ get_count_after_first, get_count_after_second,
+ "Second request should not trigger a new actor fetch (tombstone should be used). First: {}, Second: {}",
+ get_count_after_first, get_count_after_second
+ );
+
+ Ok(())
+}
frogue/tests/support/ap.rs+87−8
@@ -43,6 +43,7 @@ pub struct RemoteActor {
pub deliveries: Arc<Mutex<Vec<Delivery>>>,
pub notes: Arc<Mutex<std::collections::HashMap<String, String>>>,
pub get_requests: Arc<Mutex<Vec<GetRequest>>>,
+ pub gone: bool,
}
/// Multiple remote actors sharing a single inbox listener.
@@ -107,6 +108,7 @@ impl SharedInboxActors {
deliveries: Arc::clone(&deliveries),
notes: Arc::new(Mutex::new(std::collections::HashMap::new())),
get_requests: Arc::new(Mutex::new(Vec::new())),
+ gone: false,
});
}
@@ -252,7 +254,7 @@ impl RemoteActor {
let notes = Arc::clone(¬es_clone);
let get_requests = Arc::clone(&get_requests_clone);
thread::spawn(move || {
− handle_connection(stream, &actor_json, deliveries, notes, get_requests);
+ handle_connection(stream, &actor_json, deliveries, notes, get_requests, false);
});
}
});
@@ -266,6 +268,75 @@ impl RemoteActor {
deliveries,
notes,
get_requests,
+ gone: false,
+ }
+ }
+
+ /// Create a remote actor whose actor fetch returns 410 Gone.
+ pub fn serve_gone(port: u16) -> Self {
+ let (private_key_pem, public_key_pem) = keys::generate().expect("key generation failed");
+
+ let actor_url = format!("http://127.0.0.1:{port}/actor");
+ let inbox_url = format!("http://127.0.0.1:{port}/inbox");
+ let key_id = format!("{actor_url}#main-key");
+
+ let listener =
+ TcpListener::bind(("127.0.0.1", port)).expect("failed to bind remote actor listener");
+
+ let deliveries = Arc::new(Mutex::new(Vec::new()));
+ let deliveries_clone = Arc::clone(&deliveries);
+ let notes = Arc::new(Mutex::new(std::collections::HashMap::new()));
+ let notes_clone = Arc::clone(¬es);
+ let get_requests = Arc::new(Mutex::new(Vec::new()));
+ let get_requests_clone = Arc::clone(&get_requests);
+
+ let actor_json = json!({
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1"
+ ],
+ "id": actor_url,
+ "type": "Person",
+ "preferredUsername": "remote",
+ "name": "Remote Test User",
+ "inbox": inbox_url,
+ "publicKey": {
+ "id": key_id,
+ "owner": actor_url,
+ "publicKeyPem": public_key_pem
+ }
+ });
+ let actor_json_str = serde_json::to_string(&actor_json).unwrap();
+
+ let deliveries_for_thread = Arc::clone(&deliveries_clone);
+ let notes_for_thread = Arc::clone(¬es_clone);
+ let get_requests_for_thread = Arc::clone(&get_requests_clone);
+ thread::spawn(move || {
+ for stream in listener.incoming() {
+ let stream = match stream {
+ Ok(s) => s,
+ Err(_) => continue,
+ };
+ let actor_json = actor_json_str.clone();
+ let deliveries = Arc::clone(&deliveries_for_thread);
+ let notes = Arc::clone(¬es_for_thread);
+ let get_requests = Arc::clone(&get_requests_for_thread);
+ thread::spawn(move || {
+ handle_connection(stream, &actor_json, deliveries, notes, get_requests, true);
+ });
+ }
+ });
+
+ Self {
+ private_key_pem,
+ public_key_pem,
+ actor_url,
+ inbox_url,
+ key_id,
+ deliveries: deliveries_clone,
+ notes: notes_clone,
+ get_requests: get_requests_clone,
+ gone: true,
}
}
@@ -326,7 +397,7 @@ impl RemoteActor {
let notes = Arc::clone(¬es_for_thread);
let get_requests = Arc::clone(&get_requests_for_thread);
thread::spawn(move || {
− handle_connection(stream, &actor_json, deliveries, notes, get_requests);
+ handle_connection(stream, &actor_json, deliveries, notes, get_requests, false);
});
}
});
@@ -340,6 +411,7 @@ impl RemoteActor {
deliveries: deliveries_clone,
notes: notes_clone,
get_requests: get_requests_clone,
+ gone: false,
}
}
@@ -413,6 +485,7 @@ impl RemoteActor {
deliveries: deliveries_clone,
notes: notes_clone,
get_requests: get_requests_clone,
+ gone: false,
}
}
@@ -650,6 +723,7 @@ fn handle_connection(
deliveries: Arc<Mutex<Vec<Delivery>>>,
notes: Arc<Mutex<std::collections::HashMap<String, String>>>,
get_requests: Arc<Mutex<Vec<GetRequest>>>,
+ gone: bool,
) {
let mut buffer = [0; 8192];
let n = match stream.read(&mut buffer) {
@@ -678,12 +752,17 @@ fn handle_connection(
}
let response = if method == "GET" && path == "/actor" {
− // Respond with the actor document
− format!(
− "HTTP/1.1 200 OK\r\nContent-Type: application/activity+json\r\nContent-Length: {}\r\n\r\n{}",
− actor_json.len(),
− actor_json
− )
+ // If the actor is "gone", return 410 Gone
+ if gone {
+ "HTTP/1.1 410 Gone\r\n\r\n".to_string()
+ } else {
+ // Respond with the actor document
+ format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: application/activity+json\r\nContent-Length: {}\r\n\r\n{}",
+ actor_json.len(),
+ actor_json
+ )
+ }
} else if method == "GET" && path.starts_with("/.well-known/webfinger") {
// Extract the resource parameter from the query string
if let Some(query) = path.split('?').nth(1) {
migration/src/lib.rs+2−0
@@ -23,6 +23,7 @@ mod m20261103_000020_federate_tickets;
mod m20261103_000021_federate_patch_proposals;
mod m20261103_000022_federate_comments;
mod m20261103_000023_create_ap_remote_tickets;
+mod m20261103_000024_create_ap_remote_actor_tombstones;
pub struct Migrator;
@@ -53,6 +54,7 @@ impl MigratorTrait for Migrator {
Box::new(m20261103_000021_federate_patch_proposals::Migration),
Box::new(m20261103_000022_federate_comments::Migration),
Box::new(m20261103_000023_create_ap_remote_tickets::Migration),
+ Box::new(m20261103_000024_create_ap_remote_actor_tombstones::Migration),
]
}
}
migration/src/m20261103_000024_create_ap_remote_actor_tombstones.rs+34−0
@@ -0,0 +1,34 @@
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ // Tombstone table for tracking deleted remote actors.
+ // When we fetch a remote actor and get 410 Gone, we record it here
+ // to avoid repeatedly trying to fetch it.
+ manager
+ .get_connection()
+ .execute_unprepared(
+ "CREATE TABLE IF NOT EXISTS ap_remote_actor_tombstones (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ actor_url TEXT NOT NULL UNIQUE,
+ gone_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )",
+ )
+ .await?;
+
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .get_connection()
+ .execute_unprepared("DROP TABLE IF EXISTS ap_remote_actor_tombstones")
+ .await?;
+
+ Ok(())
+ }
+}