strip session-added comments
This commit is contained in:
@@ -10,11 +10,6 @@ use k_ap::ActivityPubService;
|
||||
|
||||
use crate::urls::actor_url;
|
||||
|
||||
/// Broadcasts the actor `Delete` to the user's accepted followers.
|
||||
///
|
||||
/// k-ap only enqueues the per-inbox deliveries; they are signed later from the
|
||||
/// tombstoned user row and keypair, which the purge keeps until the tombstone
|
||||
/// cleanup (see `AccountPurger`).
|
||||
pub struct ApDeletionNotifier {
|
||||
service: Arc<ActivityPubService>,
|
||||
instance: InstanceIdentity,
|
||||
|
||||
@@ -63,9 +63,6 @@ impl EventHandler for ActivityPubEventHandler {
|
||||
}
|
||||
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
// A deleted account must not speak again: its queued events would
|
||||
// otherwise sign and send from the tombstone after the Delete. Only
|
||||
// the Delete itself (sent by the deletion notifier) goes out.
|
||||
if let Some(author) = self.outbound_author(event)
|
||||
&& self.is_tombstoned(&author).await
|
||||
{
|
||||
@@ -173,8 +170,6 @@ impl EventHandler for ActivityPubEventHandler {
|
||||
}
|
||||
|
||||
impl ActivityPubEventHandler {
|
||||
/// The local account an outbound activity would be sent as. A queued
|
||||
/// delivery counts unless it is the account's `Delete`.
|
||||
fn outbound_author(&self, event: &DomainEvent) -> Option<UserId> {
|
||||
match event {
|
||||
DomainEvent::ReviewLogged { user_id, .. }
|
||||
|
||||
@@ -14,7 +14,6 @@ pub struct RemoteReviewUpdate<'a> {
|
||||
pub spoilers: bool,
|
||||
}
|
||||
|
||||
/// A remote review to persist, with the events that ride the same write.
|
||||
pub struct NewRemoteReview<'a> {
|
||||
pub review: &'a Review,
|
||||
pub ap_id: &'a str,
|
||||
|
||||
@@ -159,7 +159,6 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
spoilers: obj.contains_spoilers,
|
||||
});
|
||||
|
||||
// The enrichment request rides the review write (one transaction).
|
||||
let events: Vec<DomainEvent> = obj
|
||||
.external_metadata_id
|
||||
.as_ref()
|
||||
|
||||
@@ -39,7 +39,6 @@ impl CompositeSocialAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort publish for writes k_ap owns (no shared transaction).
|
||||
async fn announce(&self, events: &[DomainEvent]) {
|
||||
for event in events {
|
||||
if let Err(e) = self.event_publisher.publish(event).await {
|
||||
@@ -84,8 +83,6 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||
},
|
||||
};
|
||||
// Not atomic: k_ap owns the transaction, so the events are published
|
||||
// best-effort after its write succeeds.
|
||||
self.ap_service
|
||||
.follow(follower.value(), &handle)
|
||||
.await
|
||||
@@ -102,7 +99,6 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
) -> Result<(), DomainError> {
|
||||
match target {
|
||||
SocialIdentity::Local(_) => self.local.unfollow(follower, target, events).await,
|
||||
// Not atomic: k_ap owns the transaction.
|
||||
SocialIdentity::Remote { .. } => {
|
||||
self.ap_service
|
||||
.unfollow(follower.value(), &self.instance.actor_url_of(target))
|
||||
@@ -122,7 +118,6 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
) -> Result<(), DomainError> {
|
||||
match requester {
|
||||
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester, events).await,
|
||||
// Not atomic: k_ap owns the transaction.
|
||||
SocialIdentity::Remote { .. } => {
|
||||
self.ap_service
|
||||
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||
@@ -142,7 +137,6 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
) -> Result<(), DomainError> {
|
||||
match requester {
|
||||
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester, events).await,
|
||||
// Not atomic: k_ap owns the transaction.
|
||||
SocialIdentity::Remote { .. } => {
|
||||
self.ap_service
|
||||
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||
@@ -162,7 +156,6 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
) -> Result<(), DomainError> {
|
||||
match follower {
|
||||
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower, events).await,
|
||||
// Not atomic: k_ap owns the transaction.
|
||||
SocialIdentity::Remote { .. } => {
|
||||
self.ap_service
|
||||
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
|
||||
@@ -174,7 +167,6 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Not atomic: k_ap owns the transaction.
|
||||
async fn block(
|
||||
&self,
|
||||
blocker: &UserId,
|
||||
@@ -190,7 +182,6 @@ impl SocialCommand for CompositeSocialAdapter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Not atomic: k_ap owns the transaction.
|
||||
async fn unblock(
|
||||
&self,
|
||||
blocker: &UserId,
|
||||
|
||||
@@ -8,9 +8,6 @@ use axum::{
|
||||
};
|
||||
use domain::{ports::UserRepository, value_objects::UserId};
|
||||
|
||||
/// Answers `410 Gone` for the per-user ActivityPub endpoints of a deleted
|
||||
/// account. k-ap resolves tombstones so it can sign the account's `Delete`
|
||||
/// deliveries; this keeps that from making the actor look alive.
|
||||
pub async fn tombstone_gate(
|
||||
State(users): State<Arc<dyn UserRepository>>,
|
||||
request: Request,
|
||||
@@ -27,9 +24,6 @@ pub async fn tombstone_gate(
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// The user id in any `/users/{id}/...` path (outbox, inbox, collections,
|
||||
/// content objects such as `/users/{id}/reviews/{rid}`). The bare actor path
|
||||
/// has its own tombstone response.
|
||||
pub fn gated_user(path: &str) -> Option<uuid::Uuid> {
|
||||
let rest = path.strip_prefix("/users/")?;
|
||||
let (id, endpoint) = rest.split_once('/')?;
|
||||
|
||||
@@ -64,10 +64,6 @@ impl DomainUserRepoAdapter {
|
||||
impl ApUserRepository for DomainUserRepoAdapter {
|
||||
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
|
||||
let user_id = UserId::from_uuid(id);
|
||||
// A deleted account still resolves here, so k-ap can sign the queued
|
||||
// `Delete` deliveries until the tombstone cleanup. Username lookups
|
||||
// below stay live-only, and the AP endpoints answer 410 for it
|
||||
// (`tombstone_gate`, `actor_document_response`).
|
||||
let user = match self.repo.find_by_id(&user_id).await? {
|
||||
Some(u) => u,
|
||||
None => match self.repo.find_tombstoned_by_id(&user_id).await? {
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
//! Outbound activity ids across a redelivered event, through the real k-ap
|
||||
//! `ActivityPubService` (delivery captured via its `EventPublisher` seam).
|
||||
//!
|
||||
//! k-ap 0.5.0 derives Create ids from the object id (UUIDv5,
|
||||
//! `service/broadcast.rs:285-288`) but mints Update/Delete/Move/actor-update ids
|
||||
//! randomly (`broadcast.rs:81,129,157,210,321,343,377` -> `url_scheme.rs:53`,
|
||||
//! `Uuid::new_v4`) with no override.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -189,8 +181,6 @@ async fn activitypub_is_idempotent_when_run_twice() {
|
||||
assert_eq!(first, second, "a redelivered Create keeps its activity id");
|
||||
}
|
||||
|
||||
/// Characterises the k-ap 0.5.0 limitation recorded in ADR-0015. If k-ap starts
|
||||
/// minting deterministic Delete ids this fails: upgrade the ADR and flip it.
|
||||
#[tokio::test]
|
||||
async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
|
||||
let mut h = harness().await;
|
||||
@@ -207,8 +197,6 @@ async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
|
||||
/// Queued events of a deleted account must not sign and send from its
|
||||
/// tombstone; the account's own Delete still goes out.
|
||||
#[tokio::test]
|
||||
async fn a_deleted_account_sends_nothing_but_its_delete() {
|
||||
let mut h = harness().await;
|
||||
@@ -240,7 +228,6 @@ async fn a_deleted_account_sends_nothing_but_its_delete() {
|
||||
signing_actor_id: h.user.value(),
|
||||
};
|
||||
|
||||
// The review repository panics if the handler gets past the guard.
|
||||
for event in [&logged, &added, &queued] {
|
||||
h.handler.handle(event).await.unwrap();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ license = "MIT"
|
||||
|
||||
[features]
|
||||
default = ["federation"]
|
||||
# Enables federation-gated handlers; forwards to application/federation
|
||||
federation = ["application/federation"]
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -16,9 +16,6 @@ use axum::{Router, routing};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// JSON API routes (mounted under `/api/v1`), minus the webhook ingestion
|
||||
/// endpoints ([`webhook_router`], separately rate limited) and the
|
||||
/// federation-gated admin endpoints (`federation::api_routes`).
|
||||
pub fn router() -> Router<AppState> {
|
||||
let base = Router::new()
|
||||
.route("/diary", routing::get(diary::get_diary))
|
||||
@@ -150,7 +147,6 @@ pub fn router() -> Router<AppState> {
|
||||
base.merge(social_routes())
|
||||
}
|
||||
|
||||
/// Media-server webhook ingestion endpoints (mounted under `/api/v1`).
|
||||
pub fn webhook_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
|
||||
@@ -14,7 +14,6 @@ pub const CONFLICT: &str = "CONFLICT";
|
||||
pub const RATE_LIMITED: &str = "RATE_LIMITED";
|
||||
pub const INTERNAL: &str = "INTERNAL";
|
||||
|
||||
/// The only constructor of JSON error bodies in this crate.
|
||||
pub fn refuse(status: StatusCode, code: &'static str, message: impl Into<String>) -> Response {
|
||||
(
|
||||
status,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! JSON API extractors whose rejections are refused in the one error shape.
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
FromRequest, FromRequestParts, Multipart, Path, Query, Request,
|
||||
@@ -13,10 +11,6 @@ use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::errors::{INTERNAL, INVALID_INPUT, refuse};
|
||||
|
||||
/// Malformed request (bad JSON, query or path), refused as `INVALID_INPUT`.
|
||||
/// Status follows axum: 400 syntax, 415 content type, 422 data errors, 413 size.
|
||||
/// A rejection with a 5xx status is a server-side fault (e.g. a route that
|
||||
/// declares no path parameter), refused as `INTERNAL`.
|
||||
pub struct MalformedRequest {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
@@ -69,7 +63,6 @@ impl From<MultipartRejection> for MalformedRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// `axum::extract::Multipart` with refusals in the one error shape.
|
||||
pub struct Upload(pub Multipart);
|
||||
|
||||
impl<S> FromRequest<S> for Upload
|
||||
@@ -83,7 +76,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// `axum::Json` with refusals in the one error shape.
|
||||
pub struct Body<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequest<S> for Body<T>
|
||||
@@ -99,7 +91,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// `axum::extract::Query` with refusals in the one error shape.
|
||||
pub struct Params<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequestParts<S> for Params<T>
|
||||
@@ -115,7 +106,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// `axum::extract::Path` with refusals in the one error shape.
|
||||
pub struct PathId<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequestParts<S> for PathId<T>
|
||||
|
||||
@@ -146,7 +146,6 @@ where
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError)?
|
||||
// `None` only if the account vanished between `ensure_active` and here.
|
||||
.ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?;
|
||||
if is_admin {
|
||||
Ok(AdminApiUser(user_id))
|
||||
@@ -175,7 +174,6 @@ where
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
|
||||
// `None` only if the account vanished between `ensure_active` and here.
|
||||
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
|
||||
if is_admin {
|
||||
Ok(AdminUser(user_id))
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
//! Federation-only HTTP surface. This is the one module that decides, per
|
||||
//! build, whether federation routes exist: both builds expose the same
|
||||
//! functions, so `router.rs` and the api/html modules stay free of `cfg`.
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub(crate) mod blocklist_api;
|
||||
#[cfg(feature = "federation")]
|
||||
@@ -18,7 +14,6 @@ use axum::{Router, routing};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Classic-UI routes that only exist with federation (AP collections, admin pages).
|
||||
#[cfg(feature = "federation")]
|
||||
pub fn html_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
@@ -53,7 +48,6 @@ pub fn html_routes() -> Router<AppState> {
|
||||
)
|
||||
}
|
||||
|
||||
/// JSON API routes that only exist with federation (blocklist administration).
|
||||
#[cfg(feature = "federation")]
|
||||
pub fn api_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
@@ -80,8 +74,6 @@ pub fn api_routes() -> Router<AppState> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Without federation the follower/following collection URLs fall back to the
|
||||
/// classic list pages.
|
||||
#[cfg(not(feature = "federation"))]
|
||||
pub fn html_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
@@ -108,8 +100,6 @@ pub fn api_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
/// ActivityPub content negotiation for `GET /users/{id}`: federation builds
|
||||
/// answer AP clients with the actor document, others always render HTML.
|
||||
#[cfg(not(feature = "federation"))]
|
||||
pub async fn actor_document_response(
|
||||
_state: &AppState,
|
||||
|
||||
@@ -6,8 +6,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Answers ActivityPub clients (`Accept: application/activity+json` or
|
||||
/// `application/ld+json`) with the actor document; `None` means "render HTML".
|
||||
pub async fn actor_document_response(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
|
||||
@@ -19,7 +19,6 @@ pub fn extract_from_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Router state for [`csrf_middleware`]: whether cookies carry the `Secure` attribute.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SecureCookies(pub bool);
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ use axum::{Router, routing};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Classic server-rendered UI routes, minus the rate-limited auth forms
|
||||
/// ([`auth_router`]) and the federation-gated pages (`federation::html_routes`).
|
||||
pub fn router() -> Router<AppState> {
|
||||
let base = Router::new()
|
||||
.route("/", routing::get(diary::get_activity_feed_html))
|
||||
@@ -127,7 +125,6 @@ pub fn router() -> Router<AppState> {
|
||||
base.merge(social_routes())
|
||||
}
|
||||
|
||||
/// Login, logout and registration forms; the caller adds the auth rate limiter.
|
||||
pub fn auth_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
|
||||
@@ -233,11 +233,8 @@ pub async fn post_delete_user(
|
||||
let target_id = UserId::from_uuid(target);
|
||||
let own = actor == target_id;
|
||||
match delete_account(&state, actor, target_id, &form.password).await {
|
||||
// An admin removing their own account ends their own session too.
|
||||
Ok(()) if own => super::auth::get_logout(State(state)).await.into_response(),
|
||||
Ok(()) => axum::response::Redirect::to("/users").into_response(),
|
||||
// Same as other forbidden classic actions: a plain 403, not a redirect
|
||||
// to a page that would not show the error.
|
||||
Err(e @ DomainError::Forbidden(_)) => crate::errors::domain_error_response(e),
|
||||
Err(e) => axum::response::Redirect::to(&format!(
|
||||
"/users/{target}?error={}",
|
||||
|
||||
@@ -48,7 +48,6 @@ async fn health_handler() -> axum::Json<serde_json::Value> {
|
||||
axum::Json(serde_json::json!({ "status": "ok" }))
|
||||
}
|
||||
|
||||
/// JSON API rate-limit refusals use the one error shape.
|
||||
fn slow_down(reason: RejectionReason) -> axum::response::Response {
|
||||
match reason {
|
||||
RejectionReason::QuotaExceeded { .. } => refuse(
|
||||
@@ -64,7 +63,6 @@ fn slow_down(reason: RejectionReason) -> axum::response::Response {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `/api/v1` limiter is built here so its refusals use the one error shape.
|
||||
fn api_governor(quota: Quota) -> axum_governor::GovernorConfig<std::net::IpAddr> {
|
||||
GovernorConfigBuilder::default()
|
||||
.with_extractor(PeerIp::default())
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Request/response helpers shared by the JSON API and the classic HTML UI.
|
||||
|
||||
use crate::state::AppState;
|
||||
use domain::models::import::DomainField;
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Fixtures shared by the in-crate mapper tests.
|
||||
|
||||
use domain::value_objects::{SocialActor, SocialIdentity, UserId};
|
||||
|
||||
pub(crate) fn local_actor(uid: &UserId) -> SocialActor {
|
||||
|
||||
@@ -45,9 +45,6 @@ impl EventHandler for ImageConversionHandler {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `get` falls back to `{key}.webp` / `{key}.avif`, which would re-encode
|
||||
// an already converted image on redelivery. Probe the exact key first:
|
||||
// once the original is gone, an earlier delivery finished the conversion.
|
||||
if !self.storage.exists(&key).await? {
|
||||
tracing::warn!(%key, "original image already gone, treating conversion as done");
|
||||
return Ok(());
|
||||
|
||||
@@ -181,7 +181,6 @@ async fn image_conversion_is_idempotent_when_run_twice() {
|
||||
|
||||
handler.handle(&event).await.unwrap();
|
||||
let converted = storage.get(&new_key).await.unwrap();
|
||||
// Redelivery after the original was swapped and deleted.
|
||||
handler.handle(&event).await.unwrap();
|
||||
|
||||
assert_eq!(image_ref.swaps().len(), 1, "one swap");
|
||||
|
||||
@@ -16,7 +16,6 @@ pub(crate) struct OmdbProvider {
|
||||
|
||||
impl OmdbProvider {
|
||||
pub(crate) fn new(api_key: String) -> Self {
|
||||
// reqwest is built with rustls-no-provider; install ring so this works without bootstrap.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
|
||||
@@ -15,7 +15,6 @@ pub(crate) struct TmdbProvider {
|
||||
|
||||
impl TmdbProvider {
|
||||
pub(crate) fn new(api_key: String) -> Self {
|
||||
// reqwest is built with rustls-no-provider; install ring so this works without bootstrap.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Fresh test binary, no bootstrap: constructing the client must not panic
|
||||
//! for lack of a rustls CryptoProvider.
|
||||
#[test]
|
||||
fn omdb_client_builds_without_bootstrap() {
|
||||
let _ = metadata::MetadataClientImpl::new_omdb("key".into());
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Fresh test binary, no bootstrap: constructing the client must not panic
|
||||
//! for lack of a rustls CryptoProvider.
|
||||
#[test]
|
||||
fn tmdb_client_builds_without_bootstrap() {
|
||||
let _ = metadata::MetadataClientImpl::new_tmdb("key".into());
|
||||
|
||||
@@ -11,7 +11,6 @@ pub struct NatsConfig {
|
||||
pub subject_prefix: String,
|
||||
pub stream_name: String,
|
||||
pub consumer_name: String,
|
||||
/// JetStream redelivery bound, same as the DB queue's attempt limit.
|
||||
pub max_deliver: i64,
|
||||
}
|
||||
|
||||
|
||||
@@ -25,11 +25,8 @@ struct NatsJetStreamAckHandle {
|
||||
max_deliver: i64,
|
||||
}
|
||||
|
||||
/// How to nak a failed delivery: the same backoff schedule as the SQLite
|
||||
/// queue, keyed by JetStream's delivery count (1 on first delivery).
|
||||
struct NakPlan {
|
||||
delay: std::time::Duration,
|
||||
/// This was the last delivery `max_deliver` allows.
|
||||
exhausted: bool,
|
||||
}
|
||||
|
||||
@@ -82,9 +79,6 @@ impl AckHandle for NatsJetStreamAckHandle {
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
|
||||
/// JetStream counts every redelivery toward `max_deliver`, deferrals
|
||||
/// included. The delay is at least the remaining claim lease, so the
|
||||
/// deliveries allowed by `max_deliver` still span more than one lease.
|
||||
async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError> {
|
||||
self.message
|
||||
.ack_with(AckKind::Nak(Some(delay)))
|
||||
@@ -159,20 +153,10 @@ fn consume_from_rx(rx: EnvelopeRx) -> BoxStream<'static, Result<EventEnvelope, D
|
||||
}))
|
||||
}
|
||||
|
||||
/// How long JetStream remembers `Nats-Msg-Id`s. A row re-relayed within this
|
||||
/// window (crash between publish and mark-done, or a lease reclaim) is dropped
|
||||
/// by the broker; a duplicate arriving later is caught by the processed-event
|
||||
/// ledger, which keeps its rows for 30 days. Set explicitly so it does not
|
||||
/// silently follow the server default.
|
||||
const DEDUP_WINDOW: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
||||
|
||||
const ACK_WAIT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
/// `Some(updated)` when an existing stream's config differs from what this
|
||||
/// build needs. `get_or_create_stream` only applies config on creation, so a
|
||||
/// stream created by an older build would otherwise keep the server default
|
||||
/// duplicate window (2 minutes). Only the fields this code owns are changed;
|
||||
/// everything else the operator set is preserved.
|
||||
fn stream_config_update(existing: &StreamConfig) -> Option<StreamConfig> {
|
||||
(existing.duplicate_window != DEDUP_WINDOW).then(|| StreamConfig {
|
||||
duplicate_window: DEDUP_WINDOW,
|
||||
@@ -180,10 +164,6 @@ fn stream_config_update(existing: &StreamConfig) -> Option<StreamConfig> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `Some(updated)` when an existing durable consumer disagrees on `max_deliver`
|
||||
/// or `ack_wait`. The update is a clone of the existing config with only those
|
||||
/// two fields changed, so operator-set fields survive and immutable ones
|
||||
/// (durable name, filter subject, deliver policy) cannot mismatch.
|
||||
fn consumer_config_update(
|
||||
existing: &jetstream::consumer::Config,
|
||||
desired_max_deliver: i64,
|
||||
|
||||
@@ -12,8 +12,6 @@ use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventConsumer, EventRelaySink};
|
||||
|
||||
/// The worker's NATS side: where handlers read events from, and where the
|
||||
/// outbox relay writes them to.
|
||||
pub async fn create_bus(
|
||||
cfg: NatsConfig,
|
||||
) -> anyhow::Result<(Arc<dyn EventConsumer>, Arc<dyn EventRelaySink>)> {
|
||||
@@ -29,7 +27,6 @@ pub async fn create_bus(
|
||||
Arc::new(NatsRelaySink::new_core(client, cfg.subject_prefix)),
|
||||
)
|
||||
}
|
||||
// The consumer creates the stream the relay publishes into.
|
||||
NatsMode::JetStream => (
|
||||
Arc::new(NatsJetStreamConsumer::create(&cfg, client.clone()).await?),
|
||||
Arc::new(NatsRelaySink::new_jetstream(client, cfg.subject_prefix)),
|
||||
|
||||
@@ -9,13 +9,9 @@ use event_payload::EventPayload;
|
||||
|
||||
use crate::{payload::NatsEventMessage, subject::event_to_subject};
|
||||
|
||||
/// What goes on the wire for one outbox row. Built purely from the row, so
|
||||
/// relaying it again (after a crash between publish and mark-done) yields the
|
||||
/// same `Nats-Msg-Id` and JetStream drops the duplicate.
|
||||
pub(crate) struct RelayMessage {
|
||||
pub subject: String,
|
||||
pub payload: Vec<u8>,
|
||||
/// `None` for rows queued before identities existed: those get no dedup.
|
||||
pub msg_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -42,8 +38,6 @@ enum Transport {
|
||||
JetStream(jetstream::Context),
|
||||
}
|
||||
|
||||
/// Publishes outbox rows to NATS. JetStream mode waits for the broker ack and
|
||||
/// sets `Nats-Msg-Id`; core mode is fire-and-forget with no broker-side dedup.
|
||||
pub struct NatsRelaySink {
|
||||
transport: Transport,
|
||||
subject_prefix: String,
|
||||
|
||||
@@ -16,7 +16,6 @@ pub struct ReqwestPosterFetcher {
|
||||
|
||||
impl ReqwestPosterFetcher {
|
||||
pub fn new(config: PosterFetcherConfig) -> anyhow::Result<Self> {
|
||||
// reqwest is built with rustls-no-provider; install ring so this works without bootstrap.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(config.timeout_seconds))
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Fresh test binary, no bootstrap: constructing the client must not panic
|
||||
//! for lack of a rustls CryptoProvider.
|
||||
use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher};
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! A remote review and the enrichment request it triggers commit together.
|
||||
|
||||
use activitypub::{NewRemoteReview, RemoteReviewRepository};
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
|
||||
@@ -16,9 +16,6 @@ use tokio::sync::{Mutex, mpsc};
|
||||
use crate::shared::enqueue_events;
|
||||
pub(crate) use payload::DbEventPayload;
|
||||
|
||||
/// How long a claim holds a row. A worker that dies between claim and
|
||||
/// ack/nack strands its rows in `processing`; once the lease lapses the claim
|
||||
/// query takes them again. Must exceed the slowest handler.
|
||||
pub(crate) const CLAIM_LEASE_SECONDS: i64 = 600;
|
||||
|
||||
pub struct DbEventQueueConfig {
|
||||
@@ -140,9 +137,6 @@ struct QueueRow {
|
||||
claimed_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Claims a batch in one atomic statement. A read-then-write transaction can
|
||||
/// fail with SQLITE_BUSY_SNAPSHOT (517) when another connection commits in
|
||||
/// between, which `busy_timeout` cannot retry.
|
||||
async fn claim_batch(
|
||||
pool: &SqlitePool,
|
||||
config: &DbEventQueueConfig,
|
||||
@@ -168,13 +162,10 @@ async fn claim_batch(
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("claim batch: {e}")))?;
|
||||
|
||||
// RETURNING order is unspecified; deliver oldest first.
|
||||
rows.sort_by(|a, b| (&a.next_attempt_at, a.id).cmp(&(&b.next_attempt_at, b.id)));
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// A row that cannot be decoded can never succeed, and has no ack handle to
|
||||
/// settle it with; park it so it is neither retried forever nor left claimed.
|
||||
async fn dead_letter_undecodable(pool: &SqlitePool, row_id: i64, error: &DomainError) {
|
||||
tracing::error!(
|
||||
row_id,
|
||||
@@ -223,14 +214,10 @@ struct DbAckHandle {
|
||||
row_id: i64,
|
||||
attempts: i32,
|
||||
max_attempts: i32,
|
||||
/// The claim this handle belongs to. Settling is conditional on it, so a
|
||||
/// worker whose lease lapsed and was reclaimed cannot flip the new claim's row.
|
||||
claimed_at: Option<String>,
|
||||
}
|
||||
|
||||
impl DbAckHandle {
|
||||
/// Runs a settle statement whose last two binds are the row id and claim
|
||||
/// stamp. Zero rows changed means the claim is no longer ours.
|
||||
async fn settle<'q>(
|
||||
&self,
|
||||
what: &str,
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
//! Inline (not under `tests/`) because `claim_batch` is private and the race
|
||||
//! only shows on its claim path; two pools on a file-backed WAL database are
|
||||
//! needed, as in production.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
@@ -73,7 +69,6 @@ async fn claiming_while_another_connection_writes_does_not_fail() {
|
||||
.await
|
||||
.expect("claim must not fail while another connection writes");
|
||||
if batch.is_empty() {
|
||||
// an empty UPDATE still takes the write lock; don't starve the writer
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
claimed += batch.len();
|
||||
@@ -315,8 +310,6 @@ mod stale_handle {
|
||||
use super::{claim_batch, config};
|
||||
use crate::event_queue::{CLAIM_LEASE_SECONDS, SqliteEventQueue};
|
||||
|
||||
/// Claims a row, lets its lease lapse, and has another worker reclaim it.
|
||||
/// Returns the first (now stale) worker's envelope.
|
||||
async fn reclaimed() -> (sqlx::SqlitePool, EventEnvelope) {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
@@ -340,8 +333,6 @@ mod stale_handle {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(claim_batch(&pool, &config(10)).await.unwrap().len(), 1);
|
||||
// Real reclaims happen a lease later, so their timestamp differs from
|
||||
// the stale handle's; the claim clock has only second resolution.
|
||||
let second_claim = (chrono::Utc::now() - chrono::Duration::seconds(5))
|
||||
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||
.to_string();
|
||||
|
||||
@@ -20,8 +20,6 @@ pub use repositories::{
|
||||
create_federated_profile_query, create_image_ref, create_person_adapter, create_search_adapter,
|
||||
};
|
||||
|
||||
/// Row helpers shared with the `sqlite-federation` crate (which cannot live
|
||||
/// here because it depends on `activitypub`/`k-ap`).
|
||||
pub mod persistence {
|
||||
pub use crate::shared::{datetime_to_str, enqueue_events};
|
||||
}
|
||||
@@ -79,7 +77,6 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
|
||||
wire_pool(pool).await
|
||||
}
|
||||
|
||||
/// Migrates an already-connected pool and builds every repository on it.
|
||||
pub async fn wire_pool(pool: SqlitePool) -> anyhow::Result<SqliteWireOutput> {
|
||||
use anyhow::Context;
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ use std::time::Duration;
|
||||
use domain::errors::DomainError;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Server and worker both migrate at startup, so two processes can race on the
|
||||
/// same database. The loser's migration fails (e.g. duplicate column) because
|
||||
/// the winner already applied it; re-running re-reads `_sqlx_migrations` and
|
||||
/// skips what is done. Only an error that persists across all retries is returned.
|
||||
const RETRY_BACKOFFS: [Duration; 5] = [
|
||||
Duration::from_millis(100),
|
||||
Duration::from_millis(250),
|
||||
|
||||
@@ -9,10 +9,6 @@ use crate::shared::map_sqlx_error;
|
||||
|
||||
const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
|
||||
|
||||
/// Every table the purge empties for the account, with the column that holds
|
||||
/// the user id. `reviews` is further restricted to the user's own (non-remote)
|
||||
/// rows. A guard test compares this list with the schema, so a new
|
||||
/// user-owned table cannot be forgotten.
|
||||
pub const PURGED_USER_TABLES: &[(&str, &str)] = &[
|
||||
("reviews", "user_id"),
|
||||
("watchlist_entries", "user_id"),
|
||||
@@ -30,13 +26,8 @@ pub const PURGED_USER_TABLES: &[(&str, &str)] = &[
|
||||
("blocked_actors", "local_user_id"),
|
||||
];
|
||||
|
||||
/// Tables that reference a user but are kept until the tombstone cleanup:
|
||||
/// the keypair is needed to sign queued federation deliveries.
|
||||
pub const RETAINED_USER_TABLES: &[&str] = &["ap_local_actors"];
|
||||
|
||||
/// Columns that identify the user by actor or content URL rather than by id.
|
||||
/// The purge matches rows on them; the guard test asserts each still exists so
|
||||
/// a rename cannot silently stop the purge from clearing them.
|
||||
pub const URL_KEYED_USER_COLUMNS: &[(&str, &str)] = &[
|
||||
("ap_announces", "object_url"),
|
||||
("ap_announces", "actor_url"),
|
||||
@@ -44,9 +35,6 @@ pub const URL_KEYED_USER_COLUMNS: &[(&str, &str)] = &[
|
||||
("ap_following", "remote_actor_url"),
|
||||
];
|
||||
|
||||
/// Columns holding serialized events and activities that carry the user's id
|
||||
/// (the outbox log keeps finished rows, including authored activity JSON) and
|
||||
/// the global wrap-up reports, whose `most_active_user` names a member.
|
||||
pub const PAYLOAD_KEYED_USER_COLUMNS: &[(&str, &str)] = &[
|
||||
("event_queue", "payload"),
|
||||
("wrap_up_records", "report_json"),
|
||||
@@ -95,8 +83,6 @@ impl AccountPurger for SqliteAccountPurger {
|
||||
let base = self.instance.base_url().to_string();
|
||||
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||
|
||||
// Strip first: it claims the purge. Only a tombstoned, not yet purged
|
||||
// account matches, so a live account (or a re-run) changes nothing.
|
||||
let claimed = sqlx::query(
|
||||
"UPDATE users SET email = 'deleted-' || id || '@deleted.invalid',
|
||||
password_hash = '!purged', display_name = NULL, bio = NULL,
|
||||
@@ -113,7 +99,6 @@ impl AccountPurger for SqliteAccountPurger {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Announces of the user's content, matched before the reviews go.
|
||||
sqlx::query(
|
||||
"DELETE FROM ap_announces
|
||||
WHERE object_url IN (
|
||||
@@ -130,11 +115,6 @@ impl AccountPurger for SqliteAccountPurger {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
// The outbox log: finished events and activities authored as, or
|
||||
// mentioning, this user. Unfinished rows are never touched (they
|
||||
// belong to other users' work too and finish or dead-letter on their
|
||||
// own); the tombstone cleanup removes their history later. The
|
||||
// `UserDeleted` row that triggered this run stays until then as well.
|
||||
sqlx::query(
|
||||
"DELETE FROM event_queue
|
||||
WHERE instr(payload, ?1) > 0
|
||||
@@ -146,8 +126,6 @@ impl AccountPurger for SqliteAccountPurger {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
// Global wrap-up reports name their most active member; redact it
|
||||
// (the type allows none) and keep the rest of the report.
|
||||
sqlx::query(
|
||||
"UPDATE wrap_up_records
|
||||
SET report_json = json_set(report_json, '$.most_active_user', NULL)
|
||||
@@ -159,7 +137,6 @@ impl AccountPurger for SqliteAccountPurger {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
// Other accounts' follows of this one.
|
||||
for table in ["ap_followers", "ap_following"] {
|
||||
sqlx::query(&format!("DELETE FROM {table} WHERE remote_actor_url = ?"))
|
||||
.bind(&actor_url)
|
||||
@@ -201,9 +178,6 @@ impl AccountPurger for SqliteAccountPurger {
|
||||
) -> Result<u64, DomainError> {
|
||||
let cutoff = cutoff.format(TIMESTAMP_FORMAT).to_string();
|
||||
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||
// A tombstone whose id is still named by an unfinished event waits for
|
||||
// the next run: that event may yet be handled, and its history row
|
||||
// must be erased with the rest.
|
||||
let expiring: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT id FROM users u
|
||||
WHERE purged_at IS NOT NULL AND purged_at < ?
|
||||
@@ -217,15 +191,11 @@ impl AccountPurger for SqliteAccountPurger {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
for id in &expiring {
|
||||
// Includes the `UserDeleted` row and the `Delete` deliveries.
|
||||
sqlx::query("DELETE FROM event_queue WHERE instr(payload, ?) > 0")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
// A Follow arriving on the shared inbox after the purge can leave
|
||||
// a late row for the tombstone; delete it explicitly rather than
|
||||
// rely on the foreign-key cascade of the final `users` delete.
|
||||
for table in ["ap_followers", "ap_following"] {
|
||||
sqlx::query(&format!("DELETE FROM {table} WHERE local_user_id = ?"))
|
||||
.bind(id)
|
||||
|
||||
@@ -131,8 +131,6 @@ impl DiaryQuery for SqliteDiaryRepository {
|
||||
FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||
};
|
||||
|
||||
// Tombstoned local authors vanish from the feed; remote and orphan
|
||||
// rows have no `users` match, so `deleted_at` is NULL for them.
|
||||
where_parts.push("u.deleted_at IS NULL".to_string());
|
||||
|
||||
let where_clause = where_parts.join(" AND ");
|
||||
|
||||
@@ -26,8 +26,6 @@ pub fn create_image_ref(pool: SqlitePool) -> (Arc<dyn ImageRefCommand>, Arc<dyn
|
||||
|
||||
#[async_trait]
|
||||
impl ImageRefCommand for SqliteImageRefAdapter {
|
||||
// Tombstoned users (`deleted_at` set) are intentionally included: their
|
||||
// avatar stays referenced, and so protected from GC, until the purge.
|
||||
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
@@ -54,8 +52,6 @@ impl ImageRefCommand for SqliteImageRefAdapter {
|
||||
|
||||
#[async_trait]
|
||||
impl ImageRefQuery for SqliteImageRefAdapter {
|
||||
// Tombstoned users are intentionally visible here until the purge removes
|
||||
// the row, so their avatar is not garbage-collected early.
|
||||
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL
|
||||
|
||||
@@ -37,8 +37,6 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
// The old row still holds the external id (it is deleted last); release it
|
||||
// so the canonical upsert does not violate UNIQUE(external_metadata_id).
|
||||
sqlx::query("UPDATE movies SET external_metadata_id = NULL WHERE id = ?")
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
|
||||
@@ -6,7 +6,6 @@ use domain::{
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Matches the column default in migration 0101, so string comparison orders rows by time.
|
||||
const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
|
||||
const CLAIM_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.3fZ";
|
||||
|
||||
@@ -23,8 +22,6 @@ impl SqliteProcessedEventLedger {
|
||||
)
|
||||
}
|
||||
|
||||
/// A shorter lease than production's, so lease takeover can be tested
|
||||
/// without waiting half an hour.
|
||||
pub fn with_lease(pool: SqlitePool, lease: std::time::Duration) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
@@ -37,7 +34,6 @@ fn stamp(at: chrono::NaiveDateTime) -> String {
|
||||
at.format(TIMESTAMP_FORMAT).to_string()
|
||||
}
|
||||
|
||||
/// Millisecond precision, so two claims taken in one second have distinct tokens.
|
||||
fn claim_stamp(at: chrono::NaiveDateTime) -> String {
|
||||
at.format(CLAIM_FORMAT).to_string()
|
||||
}
|
||||
@@ -51,13 +47,9 @@ fn parse_claim_stamp(value: &str) -> Option<chrono::NaiveDateTime> {
|
||||
#[async_trait]
|
||||
impl ProcessedEventLedger for SqliteProcessedEventLedger {
|
||||
async fn begin(&self, id: &EventId, handler: &str) -> Result<Begin, DomainError> {
|
||||
// Retried once: the row can vanish (released) between the claim attempt
|
||||
// and the state lookup.
|
||||
for _ in 0..2 {
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let started = claim_stamp(now);
|
||||
// One statement, so two workers cannot both win: insert if absent,
|
||||
// or take over an in_progress claim past its lease.
|
||||
let claimed = sqlx::query(
|
||||
"INSERT INTO processed_events (event_id, handler, status, started_at, processed_at)
|
||||
VALUES (?, ?, 'in_progress', ?, ?)
|
||||
@@ -214,7 +206,6 @@ mod tests {
|
||||
token(ledger.begin(&id, "b").await.unwrap());
|
||||
token(ledger.begin(&EventId::new(), "a").await.unwrap());
|
||||
|
||||
// The blocked delivery is told to come back when the lease runs out.
|
||||
let lease = std::time::Duration::from_secs(HANDLER_LEASE_SECONDS as u64);
|
||||
assert!(retry_after <= lease && retry_after > lease - std::time::Duration::from_secs(5));
|
||||
}
|
||||
@@ -260,7 +251,6 @@ mod tests {
|
||||
insert(&pool, &id, "in_progress", "2020-01-01T00:00:00Z").await;
|
||||
|
||||
token(ledger.begin(&id, "h").await.unwrap());
|
||||
// The takeover refreshed the lease, so a third delivery is blocked.
|
||||
blocked(ledger.begin(&id, "h").await.unwrap());
|
||||
let processed_at: String = sqlx::query_scalar("SELECT processed_at FROM processed_events")
|
||||
.fetch_one(&pool)
|
||||
@@ -295,7 +285,6 @@ mod tests {
|
||||
ledger.release(&id, "a", &lapsed).await.unwrap();
|
||||
ledger.complete(&id, "a", &lapsed).await.unwrap();
|
||||
|
||||
// Still the new owner's live claim: neither call touched it.
|
||||
blocked(ledger.begin(&id, "a").await.unwrap());
|
||||
ledger.complete(&id, "a", &owner).await.unwrap();
|
||||
assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::AlreadyDone);
|
||||
|
||||
@@ -169,9 +169,6 @@ impl UserRepository for SqliteUserRepository {
|
||||
.begin()
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
// The last-admin guard lives in this single statement, so two admins
|
||||
// deleting each other concurrently cannot both pass it: the write lock
|
||||
// serialises them and the loser sees the winner's tombstone.
|
||||
let stamped = sqlx::query(
|
||||
"UPDATE users SET deleted_at = ?1 WHERE id = ?2 AND deleted_at IS NULL
|
||||
AND (role != 'admin' OR EXISTS (
|
||||
@@ -283,8 +280,6 @@ impl UserRepository for SqliteUserRepository {
|
||||
}
|
||||
|
||||
impl SqliteUserRepository {
|
||||
/// Uniqueness check over every row, tombstones included: a deleted
|
||||
/// account keeps its email and username until the purge removes the row.
|
||||
async fn row_exists(&self, column: &str, value: &str) -> Result<bool, DomainError> {
|
||||
sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE {column} = ?)"
|
||||
|
||||
@@ -52,7 +52,6 @@ impl WatchlistRepository for SqliteWatchlistRepository {
|
||||
.map_err(crate::shared::map_sqlx_error)?
|
||||
.rows_affected()
|
||||
> 0;
|
||||
// A repeat add changed nothing, so it announces nothing.
|
||||
if inserted {
|
||||
enqueue_events(&mut tx, events).await?;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@ use domain::{
|
||||
},
|
||||
};
|
||||
|
||||
/// THE outbox write path: inserts one `event_queue` row per event, each with a
|
||||
/// fresh `EventId`, inside the caller's transaction so a change and the events
|
||||
/// announcing it commit or roll back together.
|
||||
pub async fn enqueue_events(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
events: &[domain::events::DomainEvent],
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
//! The purge removes every row a deleted account owns, strips the user row to
|
||||
//! a data-free tombstone, and the cleanup later drops the tombstone itself.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -69,8 +66,6 @@ struct Seeded {
|
||||
movie_id: String,
|
||||
}
|
||||
|
||||
/// Alice owns a row in every table; bob has a little of everything, including
|
||||
/// a follow of alice and an announce of his own review, which must survive.
|
||||
async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
|
||||
let alice = test_user("alice");
|
||||
let bob = test_user("bob");
|
||||
@@ -205,7 +200,6 @@ async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
|
||||
follower_actor_url: REMOTE_ACTOR.into(),
|
||||
status: FollowStatus::Accepted,
|
||||
},
|
||||
// bob follows alice, and alice follows bob
|
||||
FollowWrite::AddFollow {
|
||||
follower_id: b.value(),
|
||||
target_actor_url: alice_actor.clone(),
|
||||
@@ -221,7 +215,6 @@ async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// A remote review attributed to the same account id is not the user's.
|
||||
exec(
|
||||
pool,
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
|
||||
@@ -234,7 +227,6 @@ async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
|
||||
],
|
||||
)
|
||||
.await;
|
||||
// Announces of alice's review and goal, of bob's review, and one by alice.
|
||||
let alice_review: String =
|
||||
sqlx::query_scalar("SELECT id FROM reviews WHERE user_id = ? AND remote_actor_url IS NULL")
|
||||
.bind(&a_str)
|
||||
@@ -352,7 +344,6 @@ async fn purge_removes_every_user_owned_row() {
|
||||
purger(&pool).purge(&s.user).await.unwrap();
|
||||
|
||||
for (table, column) in PURGED_USER_TABLES {
|
||||
// Reviews: only the remote one, asserted below, is left.
|
||||
let left = if *table == "reviews" { 1 } else { 0 };
|
||||
assert_eq!(
|
||||
count_for(&pool, table, column, &s.user).await,
|
||||
@@ -406,14 +397,12 @@ async fn purge_removes_every_user_owned_row() {
|
||||
"finished rows mentioning the user are gone; pending/processing rows (incl. other users' events about her) and bob's own rows stay"
|
||||
);
|
||||
|
||||
// The other account is untouched.
|
||||
for (table, column) in PURGED_USER_TABLES {
|
||||
assert!(
|
||||
count_for(&pool, table, column, &s.other).await > 0,
|
||||
"{table} of the other user must survive"
|
||||
);
|
||||
}
|
||||
// The keypair stays for queued deliveries.
|
||||
for table in RETAINED_USER_TABLES {
|
||||
assert_eq!(count_for(&pool, table, "user_id", &s.user).await, 1);
|
||||
}
|
||||
@@ -458,8 +447,6 @@ async fn the_purge_table_lists_cover_every_user_referencing_column_in_the_schema
|
||||
"{t} has a foreign key to users but is not in the purge lists"
|
||||
);
|
||||
}
|
||||
// Columns the purge matches by URL or payload must still exist, so a
|
||||
// rename fails here instead of silently leaving rows behind.
|
||||
for (table, column) in URL_KEYED_USER_COLUMNS
|
||||
.iter()
|
||||
.chain(PAYLOAD_KEYED_USER_COLUMNS)
|
||||
@@ -474,9 +461,6 @@ async fn the_purge_table_lists_cover_every_user_referencing_column_in_the_schema
|
||||
}
|
||||
}
|
||||
|
||||
/// `ap_activities` (k-ap's inbound dedup) holds only remote activity ids and a
|
||||
/// timestamp: nothing in it is attributable to a local user. If it ever grows a
|
||||
/// column, the purge must start clearing it.
|
||||
#[tokio::test]
|
||||
async fn ap_activities_holds_nothing_attributable_to_a_local_user() {
|
||||
let pool = migrated_pool().await;
|
||||
@@ -555,7 +539,6 @@ async fn purge_strips_the_user_row_but_keeps_the_username_and_state() {
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
// Images stay referenced until the objects are gone.
|
||||
let mut keys = p.owned_image_keys(&s.user).await.unwrap();
|
||||
keys.sort();
|
||||
assert_eq!(keys, vec!["avatars/a.webp", "banners/a.webp"]);
|
||||
@@ -571,8 +554,6 @@ async fn purging_twice_is_a_no_op() {
|
||||
parts.user.mark_deleted(&s.user, &[]).await.unwrap();
|
||||
let p = purger(&pool);
|
||||
p.purge(&s.user).await.unwrap();
|
||||
// New data cannot appear for a purged account, but a re-run must not
|
||||
// touch anything else or fail.
|
||||
let before: i64 = count(&pool, "SELECT COUNT(*) FROM reviews").await;
|
||||
let stamp: String = sqlx::query_scalar("SELECT purged_at FROM users WHERE id = ?")
|
||||
.bind(s.user.value().to_string())
|
||||
@@ -589,7 +570,6 @@ async fn purging_twice_is_a_no_op() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stamp, again);
|
||||
// And an id that never existed is fine too.
|
||||
p.purge(&UserId::generate()).await.unwrap();
|
||||
}
|
||||
|
||||
@@ -666,7 +646,6 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
|
||||
)
|
||||
.await;
|
||||
|
||||
// Younger than the cutoff: kept.
|
||||
let removed = p
|
||||
.purge_expired_tombstones(now - chrono::Duration::days(9))
|
||||
.await
|
||||
@@ -674,9 +653,6 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1);
|
||||
|
||||
// Unfinished events still name the user (the pending Delete, bob's
|
||||
// pending FollowRequested, a processing row): the tombstone waits and no
|
||||
// unfinished row is ever deleted.
|
||||
let alice = s.user.value().to_string();
|
||||
let unfinished = || async {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
@@ -696,7 +672,6 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
|
||||
assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1);
|
||||
assert_eq!(unfinished().await, before);
|
||||
|
||||
// A Follow that reached the shared inbox after the purge left a row.
|
||||
exec(
|
||||
&pool,
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at)
|
||||
@@ -704,7 +679,6 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
|
||||
&[&alice],
|
||||
)
|
||||
.await;
|
||||
// Once they finish, the cleanup takes the tombstone and the history.
|
||||
exec(&pool, "UPDATE event_queue SET status = 'done'", &[]).await;
|
||||
let removed = p
|
||||
.purge_expired_tombstones(now - chrono::Duration::days(7))
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
//! Real-database counterpart of the `InMemoryStore` cascade tests in the domain
|
||||
//! crate. Both iterate the same table lists (`domain::testing::*_CASCADE_TABLES`),
|
||||
//! so the fake and the schema cannot drift apart unnoticed.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -28,8 +24,6 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
const REMOTE_ACTOR: &str = "https://remote.example/users/ann";
|
||||
|
||||
/// Private in-memory database with plain `SqliteConnectOptions`; relies on
|
||||
/// sqlx's default `foreign_keys = ON`, which production also depends on.
|
||||
async fn migrated_pool() -> SqlitePool {
|
||||
let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
|
||||
let pool = SqlitePoolOptions::new()
|
||||
@@ -202,7 +196,6 @@ async fn seed_user_with_dependents(
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// No repository in this crate owns these two tables (federation crate).
|
||||
sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES (?, ?, 'now')")
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(REMOTE_ACTOR)
|
||||
|
||||
@@ -46,8 +46,6 @@ async fn insert_review(pool: &SqlitePool, movie: &MovieId) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Mirrors what `application::movies::merge_duplicates` builds when the
|
||||
/// canonical row does not exist.
|
||||
fn canonical_from(old: &Movie, canonical_id: MovieId) -> Movie {
|
||||
Movie::from_persistence(
|
||||
canonical_id,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Shared helpers for the transactional-outbox repository tests.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use sqlx::{
|
||||
@@ -25,8 +23,6 @@ pub async fn count(pool: &SqlitePool, table: &str) -> i64 {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Makes every later `event_queue` insert abort, so a write whose own row
|
||||
/// already landed inside the transaction must be rolled back with it.
|
||||
pub async fn fail_event_inserts(pool: &SqlitePool) {
|
||||
sqlx::query(
|
||||
"CREATE TRIGGER fail_event_insert BEFORE INSERT ON event_queue \
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Goal, watchlist and profile writes commit with their events or not at all.
|
||||
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
models::{ProfileField, UserProfile, WatchlistEntry},
|
||||
@@ -796,8 +794,6 @@ async fn a_failing_follow_write_rolls_back_the_rows_already_applied() {
|
||||
use domain::ports::{FollowCommand, FollowWrite};
|
||||
let (pool, user) = pool_with_user().await;
|
||||
let mut writes = follow_writes(user.id());
|
||||
// An unknown local user violates the ap_following foreign key after the
|
||||
// follower row was already written.
|
||||
writes.push(FollowWrite::AddFollow {
|
||||
follower_id: uuid::Uuid::new_v4(),
|
||||
target_actor_url: "https://md.example/users/other".into(),
|
||||
|
||||
@@ -120,7 +120,6 @@ async fn marking_a_user_deleted_hides_them_from_every_lookup() {
|
||||
repo.save(&admin).await.unwrap();
|
||||
let other = test_user("other");
|
||||
repo.save(&other).await.unwrap();
|
||||
// A second admin, so the first is not the last one standing.
|
||||
sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
|
||||
.bind(other.id().value().to_string())
|
||||
.execute(&pool)
|
||||
@@ -145,7 +144,6 @@ async fn marking_a_user_deleted_hides_them_from_every_lookup() {
|
||||
assert!(emails.contains(&other.email().value()));
|
||||
assert!(!emails.contains(&admin.email().value()));
|
||||
assert_eq!(repo.count_active_admins().await.unwrap(), 1);
|
||||
// The purge can still read the tombstone; a live user is not one.
|
||||
assert!(
|
||||
repo.find_tombstoned_by_id(admin.id())
|
||||
.await
|
||||
|
||||
@@ -12,7 +12,6 @@ impl TmdbEnrichmentClient {
|
||||
.tmdb_api_key
|
||||
.clone()
|
||||
.ok_or_else(|| DomainError::InfrastructureError("TMDB_API_KEY is not set".into()))?;
|
||||
// reqwest is built with rustls-no-provider; install ring so this works without bootstrap.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
Ok(Self {
|
||||
api_key,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Fresh test binary, no bootstrap: constructing the client must not panic
|
||||
//! for lack of a rustls CryptoProvider.
|
||||
use tmdb_enrichment::TmdbEnrichmentClient;
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user