diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index 86bcc897d..3fb578a21 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -340,12 +340,16 @@ FLUXER_DISCOVERY_ENABLED=true #FLUXER_AUTO_JOIN_INVITE_CODE= #FLUXER_DELETION_GRACE_PERIOD_HOURS=336 -# Report retention. A daily job deletes each report 365 days after it was +# Report retention. A daily job deletes each report this many days after it was # filed, with its evidence copies and search entry, unless a legal hold is set. -# Until this is false the job only logs what it would delete. Check the counts -# in the "Processed report retention" log line of the worker first, because -# deleted evidence cannot be restored. -#FLUXER_REPORT_RETENTION_DRY_RUN=true +#FLUXER_REPORT_RETENTION_DAYS=365 +# Delete resolved reports this many days after they were resolved, when that +# comes first. Unset leaves them to FLUXER_REPORT_RETENTION_DAYS. +#FLUXER_RESOLVED_REPORT_RETENTION_DAYS= +# true only logs what the job would delete. Deleted evidence cannot be +# restored, so check the "Processed report retention" log line of the worker +# in a dry run first if you are unsure. +#FLUXER_REPORT_RETENTION_DRY_RUN=false # Sign in with Bluesky, off unless turned on. #FLUXER_AUTH_BLUESKY_ENABLED=false diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index edc4155cf..68d08bd74 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -153,6 +153,8 @@ x-fluxer-env: &fluxer-env FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-} FLUXER_DISCOVERY_MIN_MEMBER_COUNT: ${FLUXER_DISCOVERY_MIN_MEMBER_COUNT:-} FLUXER_DELETION_GRACE_PERIOD_HOURS: ${FLUXER_DELETION_GRACE_PERIOD_HOURS:-} + FLUXER_REPORT_RETENTION_DAYS: ${FLUXER_REPORT_RETENTION_DAYS:-} + FLUXER_RESOLVED_REPORT_RETENTION_DAYS: ${FLUXER_RESOLVED_REPORT_RETENTION_DAYS:-} FLUXER_REPORT_RETENTION_DRY_RUN: ${FLUXER_REPORT_RETENTION_DRY_RUN:-} FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: ${FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES:-} diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index d52dfda6e..1c9df85d2 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -5360,6 +5360,66 @@ "required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateReportRequest"}}} } + }, + "delete": { + "operationId": "delete_admin_report", + "summary": "Delete report", + "tags": ["Admin"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Deletes a report now: its record with the captured message context and profile snapshot, its stored evidence copies, its search index entry, and the duplicate-report reservation it holds for the reporter. An evidence copy that another report also uses is kept. A report under a legal hold that has not ended is refused with REPORT_UNDER_LEGAL_HOLD. This cannot be undone. Creates an audit log entry. Requires REPORT_DELETE permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "report_id", + "in": "path", + "required": true, + "schema": { + "description": "The ID of the report", + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + }, + "description": "The ID of the report" + } + ] } }, "/admin/reports/{report_id}/legal-hold": { @@ -5411,7 +5471,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Places or clears a legal hold on a report. Reports are deleted 365 days after they were filed, together with their stored evidence, unless a hold that ends later is in place. A hold needs an end time in the future and a reason. A null legal_hold_until clears the hold. Creates an audit log entry. Requires REPORT_RESOLVE permission.", + "description": "Places or clears a legal hold on a report. A report is deleted together with its stored evidence when its retention period ends, unless a hold that ends later is in place, and a held report cannot be deleted through DELETE /admin/reports/{report_id} either. A hold needs an end time in the future and a reason. A null legal_hold_until clears the hold. Creates an audit log entry. Requires REPORT_RESOLVE permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -10221,7 +10281,7 @@ "type": "object", "properties": { "acls": { - "maxItems": 105, + "maxItems": 106, "type": "array", "items": {"$ref": "#/components/schemas/AdminAclType"}, "description": "List of access control permissions to assign" @@ -13398,7 +13458,7 @@ }, "acls": { "description": "Replacement list of access control permissions for the key", - "maxItems": 105, + "maxItems": 106, "type": "array", "items": {"$ref": "#/components/schemas/AdminAclType"} } @@ -13416,7 +13476,7 @@ "type": "string" }, "acls": { - "maxItems": 105, + "maxItems": 106, "type": "array", "items": {"type": "string"}, "description": "List of access control permissions for the key" @@ -13446,7 +13506,7 @@ "maximum": 365 }, "acls": { - "maxItems": 105, + "maxItems": 106, "type": "array", "items": {"$ref": "#/components/schemas/AdminAclType"}, "description": "List of access control permissions for the key" @@ -13467,7 +13527,7 @@ "type": "string" }, "acls": { - "maxItems": 105, + "maxItems": 106, "type": "array", "items": {"type": "string"}, "description": "List of access control permissions for the key" @@ -13480,7 +13540,7 @@ "type": "object", "properties": { "acls": { - "maxItems": 105, + "maxItems": 106, "type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 64}, "description": "Every admin access control permission the admin API recognises" @@ -13562,6 +13622,7 @@ "message:delete", "message:lookup", "message:shred", + "report:delete", "report:resolve", "report:view", "report:view:reporter_pii", @@ -16628,7 +16689,7 @@ "description": "ISO 8601 timestamp when the pending deletion was scheduled", "type": "string" }, - "acls": {"maxItems": 105, "type": "array", "items": {"type": "string"}}, + "acls": {"maxItems": 106, "type": "array", "items": {"type": "string"}}, "traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}}, "has_totp": {"type": "boolean"}, "authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}}, @@ -17238,6 +17299,7 @@ "REPORT_ALREADY_RESOLVED", "REPORT_BANNED", "REPORT_FLOW_OUTDATED", + "REPORT_UNDER_LEGAL_HOLD", "RESPONSE_VALIDATION_ERROR", "RESOURCE_LOCKED", "SERVICE_UNAVAILABLE", @@ -17533,6 +17595,7 @@ "Report already resolved.", "You've been banned from submitting reports.", "The report form changed while you were filling it in. Start the report again.", + "This report is under a legal hold and can't be deleted.", "Response validation failed: {errors}.", "This resource is being modified. Please try again shortly.", "Service unavailable.", diff --git a/fluxer_admin/src/acl.rs b/fluxer_admin/src/acl.rs index ce45258bf..859c5ec34 100644 --- a/fluxer_admin/src/acl.rs +++ b/fluxer_admin/src/acl.rs @@ -70,6 +70,7 @@ pub const MESSAGE_DELETE_ALL: &str = "message:delete_all"; pub const MESSAGE_DELETE: &str = "message:delete"; pub const MESSAGE_LOOKUP: &str = "message:lookup"; pub const MESSAGE_SHRED: &str = "message:shred"; +pub const REPORT_DELETE: &str = "report:delete"; pub const REPORT_RESOLVE: &str = "report:resolve"; pub const REPORT_VIEW: &str = "report:view"; pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii"; @@ -176,6 +177,7 @@ pub const ALL_ACLS: &[&str] = &[ MESSAGE_DELETE, MESSAGE_LOOKUP, MESSAGE_SHRED, + REPORT_DELETE, REPORT_RESOLVE, REPORT_VIEW, REPORT_VIEW_REPORTER_PII, diff --git a/fluxer_admin/src/api/reports.rs b/fluxer_admin/src/api/reports.rs index 42a99c9fb..066c140cf 100644 --- a/fluxer_admin/src/api/reports.rs +++ b/fluxer_admin/src/api/reports.rs @@ -80,6 +80,19 @@ impl AdminApiClient { .await } + pub async fn delete_report( + &self, + report_id: &str, + audit_log_reason: Option<&str>, + ) -> ApiResult<()> { + self.delete_void_with_reason( + &format!("/admin/reports/{}", urlencoding::encode(report_id)), + None, + audit_log_reason, + ) + .await + } + pub async fn search_reports( &self, params: &SearchReportsParams<'_>, diff --git a/fluxer_admin/src/middleware/flash.rs b/fluxer_admin/src/middleware/flash.rs index b9b01aac2..04dea9e55 100644 --- a/fluxer_admin/src/middleware/flash.rs +++ b/fluxer_admin/src/middleware/flash.rs @@ -93,16 +93,20 @@ pub fn clear_flash_cookie(response: &mut Response) { } pub fn redirect_with_flash(url: &str, flash: FlashData, secure: bool) -> Response { - let encoded = serialize_flash(&flash); + let mut response = Redirect::to(url).into_response(); + set_flash_cookie(&mut response, &flash, secure); + response +} + +pub fn set_flash_cookie(response: &mut Response, flash: &FlashData, secure: bool) { + let encoded = serialize_flash(flash); let secure_flag = if secure { "; Secure" } else { "" }; let cookie_value = format!( "{FLASH_COOKIE_NAME}={encoded}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60{secure_flag}" ); - let mut response = Redirect::to(url).into_response(); if let Ok(v) = HeaderValue::from_str(&cookie_value) { response.headers_mut().append(header::SET_COOKIE, v); } - response } #[cfg(test)] diff --git a/fluxer_admin/src/middleware/htmx.rs b/fluxer_admin/src/middleware/htmx.rs index 120481597..1b4f0d5f2 100644 --- a/fluxer_admin/src/middleware/htmx.rs +++ b/fluxer_admin/src/middleware/htmx.rs @@ -10,6 +10,7 @@ use axum::{ const HX_RESWAP: HeaderName = HeaderName::from_static("hx-reswap"); const ADMIN_TOAST: HeaderName = HeaderName::from_static("x-fluxer-admin-toast"); +const HX_REDIRECT: HeaderName = HeaderName::from_static("hx-redirect"); pub fn is_htmx_request(headers: &HeaderMap) -> bool { headers @@ -38,6 +39,16 @@ pub fn toast_response(flash: &FlashData) -> Response { response } +pub fn navigate_with_flash(url: &str, flash: &FlashData, secure: bool) -> Response { + let mut response = StatusCode::NO_CONTENT.into_response(); + if let Ok(value) = HeaderValue::from_str(url) { + response.headers_mut().insert(HX_REDIRECT, value); + } + add_toast_header(&mut response, flash); + flash::set_flash_cookie(&mut response, flash, secure); + response +} + pub fn add_toast_header(response: &mut Response, flash: &FlashData) { let payload = serde_json::json!({ "level": flash.flash_type, diff --git a/fluxer_admin/src/routes/reports.rs b/fluxer_admin/src/routes/reports.rs index 82c2127c2..c8afc9edb 100644 --- a/fluxer_admin/src/routes/reports.rs +++ b/fluxer_admin/src/routes/reports.rs @@ -61,6 +61,16 @@ struct LegalHoldForm { clear: Option, } +#[derive(Deserialize)] +struct DeleteForm { + #[serde(default)] + _csrf: Option, + #[serde(default)] + confirm: Option, + #[serde(default)] + audit_log_reason: Option, +} + #[derive(Deserialize)] struct ResolveForm { #[serde(default)] @@ -88,6 +98,10 @@ pub fn router() -> Router { "/reports/{report_id}/legal-hold", axum::routing::post(report_legal_hold), ) + .route( + "/reports/{report_id}/delete", + axum::routing::post(report_delete), + ) } async fn reports_list( @@ -410,6 +424,66 @@ async fn report_legal_hold( flash::redirect_with_flash(&back, flash, config.secure_cookies()) } +async fn report_delete( + State(state): State, + auth: axum::Extension, + Path(report_id): Path, + request: Request, +) -> Response { + let config = state.config(); + let base = &config.base_path; + let back = format!("{base}/reports/{report_id}"); + let navigates = htmx::is_htmx_request(request.headers()) + && htmx::targets(request.headers(), "flash-container"); + let form: DeleteForm = match Form::from_request(request, &state).await { + Ok(Form(f)) => f, + Err(error) => { + tracing::warn!(%error, report_id, "failed to parse report delete form"); + return flash::redirect_with_flash( + &back, + FlashData::error("Invalid form data"), + config.secure_cookies(), + ); + } + }; + if form.confirm.as_deref() != Some("true") { + return flash::redirect_with_flash( + &back, + FlashData::error(DELETE_NEEDS_CONFIRMATION), + config.secure_cookies(), + ); + } + let audit_log_reason = clean_string(form.audit_log_reason.as_deref().unwrap_or("")); + let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); + match client + .delete_report(&report_id, audit_log_reason.as_deref()) + .await + { + Ok(()) => { + let list = format!("{base}/reports"); + let flash = FlashData::success("Report deleted"); + if navigates { + htmx::navigate_with_flash(&list, &flash, config.secure_cookies()) + } else { + flash::redirect_with_flash(&list, flash, config.secure_cookies()) + } + } + Err(error) => { + tracing::warn!(%error, report_id, "admin API request failed: delete report"); + let message = match error { + ApiError::Http { status: 409, .. } => DELETE_REFUSED_HELD, + ApiError::Http { status: 404, .. } => "The report no longer exists", + _ => "Failed to delete the report", + }; + flash::redirect_with_flash(&back, FlashData::error(message), config.secure_cookies()) + } + } +} + +const DELETE_NEEDS_CONFIRMATION: &str = "Confirm that the report should be deleted"; +const DELETE_REFUSED_HELD: &str = + "The report is under a legal hold. Clear the hold before deleting it."; + const LEGAL_HOLD_IN_PAST: &str = "The hold must end in the future"; const LEGAL_HOLD_NEEDS_REASON: &str = "Give a reason for the hold"; diff --git a/fluxer_admin/src/templates/pages/report_detail.rs b/fluxer_admin/src/templates/pages/report_detail.rs index 071296194..b941d0eba 100644 --- a/fluxer_admin/src/templates/pages/report_detail.rs +++ b/fluxer_admin/src/templates/pages/report_detail.rs @@ -19,7 +19,8 @@ use crate::{ data_field_text, }, form::{ - FORM_CONTROL_CLASS, FORM_LABEL_CLASS, csrf_input, opt_out_checkbox, select_chevron, + FORM_CONTROL_CLASS, FORM_LABEL_CLASS, checkbox, csrf_input, danger_button, + opt_out_checkbox, select_chevron, }, media::{guild_icon_url, initials, user_avatar_url}, message_data::ordered_messages, @@ -963,7 +964,7 @@ fn legal_hold_card( } @if ended { p class="text-neutral-500 text-sm" { - "The report is no longer held and is deleted 365 days after it was filed." + "The report is no longer held and is deleted when its retention period ends." } } @if let Some(reason) = non_empty(report.legal_hold_reason.as_deref()) { @@ -974,7 +975,7 @@ fn legal_hold_card( } } @else { p class="text-neutral-500 text-sm" { - "No hold. The report and its stored evidence are deleted 365 days after it was filed." + "No hold. The report and its stored evidence are deleted when its retention period ends." } } @if can_edit { @@ -1015,6 +1016,51 @@ fn legal_hold_card( } } +pub const DELETE_REPORT_CONFIRMATION: &str = + "I understand this permanently deletes the report and its stored evidence"; + +fn delete_report_card( + config: &AdminConfig, + auth: &AuthContext, + report: &ReportEntry, + csrf_token: &str, +) -> Markup { + if !has_acl(auth, acl::REPORT_DELETE) { + return html! {}; + } + let base = &config.base_path; + let held = non_empty(report.legal_hold_until.as_deref()) + .is_some_and(|until| !legal_hold_has_ended(until)); + html! { + (section_card(Some("Delete Report"), None, None, html! { + div class="flex flex-col gap-3" data-report-delete=(if held { "held" } else { "available" }) { + p class="text-neutral-500 text-sm" { + "Deletes the report now, with its message context, profile snapshot, stored evidence and search entry. \ + Evidence that another report uses is kept. This cannot be undone." + } + @if held { + p class="text-neutral-700 text-sm" { + "The report is under a legal hold. Clear the hold to delete it." + } + } @else { + form method="post" action={(base) "/reports/" (report.report_id) "/delete"} + class="flex flex-col gap-3" data-report-delete-form="" { + (csrf_input(csrf_token)) + label for="delete_audit_log_reason" class=(FORM_LABEL_CLASS) { + "Audit log reason (optional)" + } + input type="text" id="delete_audit_log_reason" name="audit_log_reason" maxlength="512" + placeholder="Why this report is being deleted" + class={(FORM_CONTROL_CLASS) " h-8 px-3 py-1.5"}; + (checkbox("confirm", "true", DELETE_REPORT_CONFIRMATION, false, true)) + (danger_button("Delete Report")) + } + } + } + })) + } +} + fn legal_hold_has_ended(until: &str) -> bool { time::OffsetDateTime::parse(until, &time::format_description::well_known::Rfc3339) .is_ok_and(|until| until <= time::OffsetDateTime::now_utc()) @@ -1098,6 +1144,7 @@ pub fn report_detail_page( (status_card(config, report)) (actions_card(config, report, csrf_token)) (legal_hold_card(config, auth, report, csrf_token)) + (delete_report_card(config, auth, report, csrf_token)) } } } @@ -2321,7 +2368,7 @@ mod tests { "{markup}" ); assert!( - markup.contains("deleted 365 days after it was filed"), + markup.contains("deleted when its retention period ends"), "{markup}" ); assert!( @@ -2391,4 +2438,63 @@ mod tests { assert!(!markup.contains(">Update Hold<"), "{markup}"); assert!(markup.contains(">Clear Hold<"), "{markup}"); } + + #[test] + fn delete_card_needs_report_delete_and_asks_for_confirmation() { + let config = test_config(); + let deleter = auth_with_acls(&[acl::REPORT_VIEW, acl::REPORT_DELETE]); + let resolver = auth_with_acls(&[acl::REPORT_VIEW, acl::REPORT_RESOLVE]); + + let open = report(json!({"status": 1})); + let markup = delete_report_card(&config, &deleter, &open, "csrf").into_string(); + assert!(markup.contains(">Delete Report<"), "{markup}"); + assert!( + markup.contains(r#"data-report-delete="available""#), + "{markup}" + ); + assert!( + markup.contains(r#"action="/admin/reports/1800000000000000001/delete""#), + "{markup}" + ); + assert!( + markup.contains(r#"type="checkbox" name="confirm" value="true""#), + "{markup}" + ); + assert!(markup.contains(DELETE_REPORT_CONFIRMATION), "{markup}"); + assert!(markup.contains(r#"name="audit_log_reason""#), "{markup}"); + assert!(markup.contains("This cannot be undone."), "{markup}"); + assert!( + !markup.contains("data-admin-refresh-on-success"), + "{markup}" + ); + + let held = report(json!({ + "legal_hold_until": "2099-01-31T23:59:59.999Z", + "legal_hold_reason": "Court order 42" + })); + let markup = delete_report_card(&config, &deleter, &held, "csrf").into_string(); + assert!(markup.contains(r#"data-report-delete="held""#), "{markup}"); + assert!(markup.contains("Clear the hold to delete it."), "{markup}"); + assert!(!markup.contains(">::new())); + let sink = received.clone(); + let api = Router::new() + .route( + "/admin/reports/1800000000000000001", + routing::delete(move |headers: HeaderMap| { + let sink = sink.clone(); + async move { + let reason = headers + .get("X-Audit-Log-Reason") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + sink.lock().unwrap().push(reason.clone()); + if reason.as_deref() == Some("held") { + return ( + StatusCode::CONFLICT, + Json(json!({ + "code": "REPORT_UNDER_LEGAL_HOLD", + "message": "This report is under a legal hold and can't be deleted." + })), + ) + .into_response(); + } + StatusCode::NO_CONTENT.into_response() + } + }) + .get(|| async { json_response(searched_report()) }), + ) + .fallback(mock_api); + let app = setup_with_api(api).await; + let (headers, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await; + let csrf_token = csrf_cookie(&headers) + .unwrap_or_else(|| panic!("report page did not set csrf_token cookie\n{page}")); + assert_form_has_csrf(&page, "/reports/1800000000000000001/delete", &csrf_token); + let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token); + let post = |body: String| { + let app = &app; + let cookie = cookie.clone(); + async move { + post_form_with_headers( + app, + "/reports/1800000000000000001/delete", + &[ + ("HX-Request", "true"), + ("HX-Target", "flash-container"), + ("Cookie", &cookie), + ], + &body, + ) + .await + } + }; + let toast = |headers: &HeaderMap| { + headers + .get("X-Fluxer-Admin-Toast") + .and_then(|value| value.to_str().ok()) + .unwrap_or_else(|| panic!("missing toast header {headers:?}")) + .to_owned() + }; + + let (status, headers, text) = post(format!( + "_csrf={csrf_token}&audit_log_reason=Erasure+request" + )) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "{text}"); + let message = toast(&headers); + assert!(message.contains("error"), "{message}"); + assert!( + message.contains("Confirm that the report should be deleted"), + "{message}" + ); + assert!(headers.get("HX-Redirect").is_none(), "{headers:?}"); + + let (status, headers, text) = post(format!( + "_csrf={csrf_token}&confirm=true&audit_log_reason=Erasure+request" + )) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "{text}"); + assert_eq!( + headers.get("HX-Redirect").and_then(|v| v.to_str().ok()), + Some("/reports"), + "{headers:?}" + ); + let message = toast(&headers); + assert!(message.contains("success"), "{message}"); + assert!(message.contains("Report deleted"), "{message}"); + assert!( + headers + .get_all(header::SET_COOKIE) + .iter() + .any(|value| value.to_str().is_ok_and(|v| v.starts_with("flash="))), + "{headers:?}" + ); + + let (status, headers, text) = post(format!( + "_csrf={csrf_token}&confirm=true&audit_log_reason=held" + )) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "{text}"); + assert!(headers.get("HX-Redirect").is_none(), "{headers:?}"); + let message = toast(&headers); + assert!(message.contains("error"), "{message}"); + assert!( + message.contains("The report is under a legal hold. Clear the hold before deleting it."), + "{message}" + ); + + let (status, headers, text) = post_form_with_headers( + &app, + "/reports/1800000000000000001/delete", + &[("Cookie", &cookie)], + &format!("_csrf={csrf_token}&confirm=true&audit_log_reason=+"), + ) + .await; + assert!(status.is_redirection(), "{status} {text}"); + assert_eq!( + headers.get(header::LOCATION).and_then(|v| v.to_str().ok()), + Some("/reports"), + "{headers:?}" + ); + + assert_eq!( + *received.lock().unwrap(), + vec![ + Some("Erasure request".to_owned()), + Some("held".to_owned()), + None + ] + ); +} + #[tokio::test] async fn report_actions_toast_the_server_message_and_refresh_the_detail() { let app = setup().await; diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index 24da902c9..9db9646bb 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -448,6 +448,11 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { attachmentDecayEnabled: master.attachment_decay_enabled, deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours, inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days, + reportRetention: { + days: master.report_retention.days, + resolvedDays: master.report_retention.resolved_days, + dryRun: master.report_retention.dry_run, + }, push: { publicVapidKey: master.auth.vapid.public_key, apns: { diff --git a/fluxer_api/src/api/admin/controllers/ReportAdminController.ts b/fluxer_api/src/api/admin/controllers/ReportAdminController.ts index 455c1adbe..89cf36044 100644 --- a/fluxer_api/src/api/admin/controllers/ReportAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/ReportAdminController.ts @@ -8,6 +8,8 @@ import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; import {getReportRepository} from '@app/api/middleware/ServiceSingletons'; import {RateLimitConfigs} from '@app/api/RateLimitConfig'; +import {deleteReportNow} from '@app/api/report/ReportDeletion'; +import {getReportSearchService} from '@app/api/SearchFactory'; import type {HonoApp} from '@app/api/types/HonoEnv'; import {Validator} from '@app/api/Validator'; import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; @@ -241,7 +243,7 @@ export function ReportAdminController(app: HonoApp) { operationId: 'set_admin_report_legal_hold', summary: 'Set report legal hold', description: - 'Places or clears a legal hold on a report. Reports are deleted 365 days after they were filed, together with their stored evidence, unless a hold that ends later is in place. A hold needs an end time in the future and a reason. A null legal_hold_until clears the hold. Creates an audit log entry. Requires REPORT_RESOLVE permission.', + 'Places or clears a legal hold on a report. A report is deleted together with its stored evidence when its retention period ends, unless a hold that ends later is in place, and a held report cannot be deleted through DELETE /admin/reports/{report_id} either. A hold needs an end time in the future and a reason. A null legal_hold_until clears the hold. Creates an audit log entry. Requires REPORT_RESOLVE permission.', responseSchema: ReportLegalHoldResponse, statusCode: 200, security: 'adminApiKey', @@ -289,4 +291,48 @@ export function ReportAdminController(app: HonoApp) { }); }, ); + app.delete( + '/admin/reports/:report_id', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.REPORT_DELETE), + Validator('param', ReportIdParam), + OpenAPI({ + operationId: 'delete_admin_report', + summary: 'Delete report', + description: + 'Deletes a report now: its record with the captured message context and profile snapshot, its stored evidence copies, its search index entry, and the duplicate-report reservation it holds for the reporter. An evidence copy that another report also uses is kept. A report under a legal hold that has not ended is refused with REPORT_UNDER_LEGAL_HOLD. This cannot be undone. Creates an audit log entry. Requires REPORT_DELETE permission.', + responseSchema: null, + statusCode: 204, + security: 'adminApiKey', + tags: 'Admin', + }), + async (ctx) => { + const {report_id} = ctx.req.valid('param'); + const reportId = createReportID(report_id); + const deleted = await deleteReportNow( + { + reportRepository: getReportRepository(), + storageService: ctx.get('storageService'), + reportSearchService: getReportSearchService(), + }, + reportId, + new Date(), + ); + await ctx.get('adminService').auditService.createAuditLog({ + adminUserId: ctx.get('adminUserId'), + targetType: 'report', + targetId: BigInt(reportId), + action: 'delete_report', + auditLogReason: ctx.get('auditLogReason'), + metadata: new Map([ + ['report_id', reportId.toString()], + ['report_type', deleted.report.reportType.toString()], + ['status', deleted.report.status.toString()], + ['objects_deleted', deleted.objectsDeleted.toString()], + ['shared_objects_kept', deleted.sharedObjectsKept.toString()], + ]), + }); + return ctx.body(null, 204); + }, + ); } diff --git a/fluxer_api/src/api/admin/tests/AdminEndpointsAuthorization.test.ts b/fluxer_api/src/api/admin/tests/AdminEndpointsAuthorization.test.ts index c3e6f1377..921357c60 100644 --- a/fluxer_api/src/api/admin/tests/AdminEndpointsAuthorization.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminEndpointsAuthorization.test.ts @@ -32,6 +32,7 @@ const adminEndpoints: Array = [ {method: 'GET', path: '/admin/reports', requiredACL: 'report:view'}, {method: 'GET', path: '/admin/reports/1', requiredACL: 'report:view'}, {method: 'PATCH', path: '/admin/reports/1', requiredACL: 'report:resolve'}, + {method: 'DELETE', path: '/admin/reports/1', requiredACL: 'report:delete'}, { method: 'POST', path: '/admin/bulk-jobs', diff --git a/fluxer_api/src/api/admin/tests/AdminReportDelete.test.ts b/fluxer_api/src/api/admin/tests/AdminReportDelete.test.ts new file mode 100644 index 000000000..05d03f509 --- /dev/null +++ b/fluxer_api/src/api/admin/tests/AdminReportDelete.test.ts @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {AdminAuditLog} from '@app/api/admin/IAdminRepository'; +import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import { + createAttachmentID, + createChannelID, + createMessageID, + createReportID, + createUserID, + type ReportID, +} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; +import {makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers'; +import { + IAR_SUBMISSION_COLUMNS, + type IARMessageContextRow, + type IARSubmissionRow, +} from '@app/api/database/types/ReportTypes'; +import {getAdminRepository} from '@app/api/middleware/ServiceSingletons'; +import {ReportRepository} from '@app/api/report/ReportRepository'; +import {getReportSearchService} from '@app/api/SearchFactory'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {serializeReportProfileSnapshot} from '@fluxer/schema/src/domains/report/ReportProfileSnapshotSchemas'; +import {ms} from 'itty-time'; +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; + +const REPORTS_BUCKET = Config.s3.buckets.reports; +const AUDIT_REASON = 'Erasure request 77'; +let sequence = 1_498_000_000_000_000_000n; + +function nextId(): bigint { + sequence += 1n; + return sequence; +} + +interface Evidence { + channelId: bigint; + attachmentId: bigint; + filename: string; +} + +interface SeededReport { + reportId: ReportID; + reporterId: bigint; + channelId: bigint; + messageId: bigint; + attachmentKey: string; + profileKey: string; +} + +function evidenceKey(evidence: Evidence): string { + return makeAttachmentCdnKey(createChannelID(evidence.channelId), evidence.attachmentId, evidence.filename); +} + +function contextRow(evidence: Evidence, authorId: bigint): IARMessageContextRow { + return { + message_id: nextId(), + channel_id: evidence.channelId, + author_id: authorId, + webhook_id: null, + author_username: 'reported', + author_discriminator: 1, + author_avatar_hash: null, + content: 'the reported message', + timestamp: new Date(), + edited_timestamp: null, + type: 0, + flags: 0, + mention_everyone: false, + mention_users: null, + mention_roles: null, + mention_channels: null, + attachments: [ + { + attachment_id: createAttachmentID(evidence.attachmentId), + filename: evidence.filename, + size: 1024n, + title: null, + description: null, + width: 64, + height: 64, + content_type: 'image/png', + content_hash: null, + placeholder: null, + flags: 0, + duration: null, + nsfw: false, + waveform: null, + }, + ], + embeds: null, + sticker_items: null, + }; +} + +describe('DELETE /admin/reports/:report_id', () => { + let harness: ApiTestHarness; + let admin: TestAccount; + let repository: ReportRepository; + + beforeEach(async () => { + harness = await createApiTestHarness({search: 'enabled'}); + admin = await setUserACLs(harness, await createTestAccount(harness), [ + 'admin:authenticate', + AdminACLs.REPORT_VIEW, + AdminACLs.REPORT_DELETE, + ]); + repository = new ReportRepository(); + }); + + afterEach(async () => { + await harness?.shutdown(); + }); + + async function seedMessageReport( + evidence: Evidence, + options: {legalHoldUntil?: Date | null} = {}, + ): Promise { + const id = nextId(); + const reportId = createReportID(id); + const reporterId = nextId(); + const reportedUserId = nextId(); + const messageId = nextId(); + const attachmentKey = evidenceKey(evidence); + const profileKey = `reports/${reportId}/profile/user_avatar/abc${id}`; + await harness.storageService.uploadObject({bucket: REPORTS_BUCKET, key: attachmentKey, body: new Uint8Array([1])}); + await harness.storageService.uploadObject({bucket: REPORTS_BUCKET, key: profileKey, body: new Uint8Array([2])}); + const report = await repository.createReport({ + ...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])), + report_id: id, + reporter_id: reporterId, + reported_at: new Date(Date.now() - ms('1 day')), + status: 1, + resolved_at: new Date(), + report_type: 0, + category: 'spam', + reported_user_id: reportedUserId, + reported_channel_id: evidence.channelId, + reported_message_id: messageId, + message_context: [contextRow(evidence, reportedUserId)], + reported_profile_snapshot: serializeReportProfileSnapshot({ + captured_at: new Date().toISOString(), + user: { + id: reportedUserId.toString(), + username: 'reported', + discriminator: 1, + global_name: null, + bio: null, + pronouns: null, + avatar: {hash: `abc${id}`, key: profileKey}, + banner: null, + }, + member: null, + guild: null, + }), + legal_hold_until: options.legalHoldUntil ?? null, + legal_hold_reason: options.legalHoldUntil ? 'Preservation request' : null, + } as IARSubmissionRow); + await getReportSearchService()!.indexReport(report); + const reserved = await repository.reserveMessageReportByReporter({ + reporter_id: reporterId, + channel_id: evidence.channelId, + message_id: messageId, + report_id: id, + reported_at: report.reportedAt, + }); + expect(reserved).toBe(true); + return {reportId, reporterId, channelId: evidence.channelId, messageId, attachmentKey, profileKey}; + } + + function newEvidence(): Evidence { + return {channelId: nextId(), attachmentId: nextId(), filename: 'evidence.png'}; + } + + async function indexedIds(): Promise> { + const result = await getReportSearchService()!.searchReports('', {}, {limit: 1000}); + return result.hits.map((hit) => hit.id); + } + + function reservationIsFree(seeded: SeededReport): Promise { + return repository.reserveMessageReportByReporter({ + reporter_id: createUserID(seeded.reporterId), + channel_id: createChannelID(seeded.channelId), + message_id: createMessageID(seeded.messageId), + report_id: createReportID(nextId()), + reported_at: new Date(), + }); + } + + function deleteRequest(account: TestAccount, reportId: ReportID | string) { + return createBuilder<{code?: string}>(harness, account.token) + .delete(`/admin/reports/${reportId}`) + .header('X-Audit-Log-Reason', AUDIT_REASON); + } + + async function auditEntries(action: string): Promise> { + const logs = await getAdminRepository().listAllAuditLogsPaginated(10000); + return logs.filter((log) => log.action === action); + } + + test('deletes the record, message context, evidence, search entry and reservation, and records the reason', async () => { + const seeded = await seedMessageReport(newEvidence()); + const report = await repository.getReport(seeded.reportId); + expect(report?.messageContext).toHaveLength(1); + expect(await indexedIds()).toContain(seeded.reportId.toString()); + + await deleteRequest(admin, seeded.reportId).expect(HTTP_STATUS.NO_CONTENT).execute(); + + expect(await repository.getReport(seeded.reportId)).toBeNull(); + expect(harness.storageService.hasObject(REPORTS_BUCKET, seeded.attachmentKey)).toBe(false); + expect(harness.storageService.hasObject(REPORTS_BUCKET, seeded.profileKey)).toBe(false); + expect(await indexedIds()).not.toContain(seeded.reportId.toString()); + expect(await reservationIsFree(seeded)).toBe(true); + const [entry, ...rest] = await auditEntries('delete_report'); + expect(rest).toEqual([]); + expect(entry?.adminUserId.toString()).toBe(admin.userId); + expect(entry?.targetType).toBe('report'); + expect(entry?.targetId.toString()).toBe(seeded.reportId.toString()); + expect(entry?.auditLogReason).toBe(AUDIT_REASON); + expect(Object.fromEntries(entry?.metadata ?? [])).toEqual({ + report_id: seeded.reportId.toString(), + report_type: '0', + status: '1', + objects_deleted: '2', + shared_objects_kept: '0', + }); + }); + + test('keeps an evidence copy another report still uses', async () => { + const shared = newEvidence(); + const deleted = await seedMessageReport(shared); + const other = await seedMessageReport(shared); + + await deleteRequest(admin, deleted.reportId).expect(HTTP_STATUS.NO_CONTENT).execute(); + + expect(await repository.getReport(deleted.reportId)).toBeNull(); + expect(harness.storageService.hasObject(REPORTS_BUCKET, deleted.profileKey)).toBe(false); + expect(harness.storageService.hasObject(REPORTS_BUCKET, evidenceKey(shared))).toBe(true); + expect(await repository.getReport(other.reportId)).not.toBeNull(); + expect(harness.storageService.hasObject(REPORTS_BUCKET, other.profileKey)).toBe(true); + expect(await indexedIds()).toContain(other.reportId.toString()); + expect(await reservationIsFree(other)).toBe(false); + const [entry] = await auditEntries('delete_report'); + expect(Object.fromEntries(entry?.metadata ?? [])).toMatchObject({objects_deleted: '1', shared_objects_kept: '1'}); + }); + + test('refuses a report under a legal hold and changes nothing', async () => { + const seeded = await seedMessageReport(newEvidence(), {legalHoldUntil: new Date(Date.now() + ms('30 days'))}); + + const response = await deleteRequest(admin, seeded.reportId).expect(HTTP_STATUS.CONFLICT).execute(); + + expect(response.code).toBe(APIErrorCodes.REPORT_UNDER_LEGAL_HOLD); + expect(await repository.getReport(seeded.reportId)).not.toBeNull(); + expect(harness.storageService.hasObject(REPORTS_BUCKET, seeded.attachmentKey)).toBe(true); + expect(harness.storageService.hasObject(REPORTS_BUCKET, seeded.profileKey)).toBe(true); + expect(await indexedIds()).toContain(seeded.reportId.toString()); + expect(await reservationIsFree(seeded)).toBe(false); + expect(await auditEntries('delete_report')).toEqual([]); + }); + + test('deletes a report whose legal hold has ended', async () => { + const seeded = await seedMessageReport(newEvidence(), {legalHoldUntil: new Date(Date.now() - ms('1 minute'))}); + + await deleteRequest(admin, seeded.reportId).expect(HTTP_STATUS.NO_CONTENT).execute(); + + expect(await repository.getReport(seeded.reportId)).toBeNull(); + }); + + test('an unknown report gives UNKNOWN_REPORT and records nothing', async () => { + await deleteRequest(admin, '1499999999999999999') + .expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_REPORT) + .execute(); + + expect(await auditEntries('delete_report')).toEqual([]); + }); + + test('needs REPORT_DELETE, which REPORT_RESOLVE does not grant', async () => { + const seeded = await seedMessageReport(newEvidence()); + const resolver = await setUserACLs(harness, await createTestAccount(harness), [ + 'admin:authenticate', + AdminACLs.REPORT_VIEW, + AdminACLs.REPORT_RESOLVE, + ]); + + await deleteRequest(resolver, seeded.reportId).expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL').execute(); + + expect(await repository.getReport(seeded.reportId)).not.toBeNull(); + expect(harness.storageService.hasObject(REPORTS_BUCKET, seeded.attachmentKey)).toBe(true); + expect(await auditEntries('delete_report')).toEqual([]); + }); +}); diff --git a/fluxer_api/src/api/admin/tests/AdminReportLegalHold.test.ts b/fluxer_api/src/api/admin/tests/AdminReportLegalHold.test.ts index 5bc063b89..c8ebafab8 100644 --- a/fluxer_api/src/api/admin/tests/AdminReportLegalHold.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminReportLegalHold.test.ts @@ -70,7 +70,7 @@ describe('report legal hold', () => { async function runRetention() { return processReportRetention( {reportRepository: repository, storageService: new MockStorageService(), reportSearchService: null}, - {now: new Date(), dryRun: false}, + {now: new Date(), dryRun: false, policy: {retentionDays: 365, resolvedRetentionDays: null}}, ); } diff --git a/fluxer_api/src/api/admin/tests/audit_coverage/ReportAdminAuditCases.ts b/fluxer_api/src/api/admin/tests/audit_coverage/ReportAdminAuditCases.ts index 8c0346301..90beb7487 100644 --- a/fluxer_api/src/api/admin/tests/audit_coverage/ReportAdminAuditCases.ts +++ b/fluxer_api/src/api/admin/tests/audit_coverage/ReportAdminAuditCases.ts @@ -277,6 +277,28 @@ export const ReportAdminAuditCases: ReadonlyArray = [ }; }, }, + { + method: 'DELETE', + route: '/admin/reports/:report_id', + async prepare(context) { + const {reportId} = await fileUserReport(context); + return { + request: {path: `/admin/reports/${reportId}`, expectStatus: 204}, + expected: { + action: 'delete_report', + targetType: 'report', + targetId: reportId, + metadata: { + report_id: reportId, + report_type: '1', + status: '0', + objects_deleted: '0', + shared_objects_kept: '0', + }, + }, + }; + }, + }, { method: 'POST', route: '/admin/reports/:report_id/legal-hold', diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index 542026320..3e76b328d 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -312,6 +312,11 @@ export interface APIConfig { presignedHarvestDownloadsEnabled: boolean; attachmentDecayEnabled: boolean; deletionGracePeriodHours: number; + reportRetention: { + days: number; + resolvedDays: number | null; + dryRun: boolean; + }; inactivityDeletionThresholdDays?: number; push: { publicVapidKey?: string; diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 9f2e7daa0..960f704ac 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -39762,6 +39762,7 @@ "REPORT_ALREADY_RESOLVED", "REPORT_BANNED", "REPORT_FLOW_OUTDATED", + "REPORT_UNDER_LEGAL_HOLD", "RESPONSE_VALIDATION_ERROR", "RESOURCE_LOCKED", "SERVICE_UNAVAILABLE", @@ -40057,6 +40058,7 @@ "Report already resolved.", "You've been banned from submitting reports.", "The report form changed while you were filling it in. Start the report again.", + "This report is under a legal hold and can't be deleted.", "Response validation failed: {errors}.", "This resource is being modified. Please try again shortly.", "Service unavailable.", diff --git a/fluxer_api/src/api/report/ReportDeletion.ts b/fluxer_api/src/api/report/ReportDeletion.ts new file mode 100644 index 000000000..c4b104938 --- /dev/null +++ b/fluxer_api/src/api/report/ReportDeletion.ts @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ReportID} from '@app/api/BrandedTypes'; +import {Config} from '@app/api/Config'; +import {makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers'; +import type {IStorageService} from '@app/api/infrastructure/IStorageService'; +import type {IARSubmission, IReportRepository} from '@app/api/report/IReportRepository'; +import type {IReportSearchService} from '@app/api/search/IReportSearchService'; +import {ReportUnderLegalHoldError} from '@fluxer/errors/src/domains/moderation/ReportUnderLegalHoldError'; +import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError'; +import {listReportProfileSnapshotAssets} from '@fluxer/schema/src/domains/report/ReportProfileSnapshotSchemas'; + +const SCAN_PAGE_SIZE = 500; + +export interface ReportDeletionDeps { + reportRepository: IReportRepository; + storageService: IStorageService; + reportSearchService: IReportSearchService | null; +} + +export interface ReportEvidenceLedger { + retainedKeys: Set; + deletedKeys: Set; +} + +export interface ReportDeletionResult { + objectsDeleted: number; + sharedObjectsKept: number; +} + +export function isReportUnderLegalHold(report: IARSubmission, now: Date): boolean { + return report.legalHoldUntil != null && report.legalHoldUntil.getTime() > now.getTime(); +} + +export function referencedObjectKeys(report: IARSubmission): Array { + const keys: Array = []; + for (const message of report.messageContext ?? []) { + const channelId = message.channelId ?? report.reportedChannelId; + if (!channelId) continue; + for (const attachment of message.attachments) { + if (attachment.attachment_id == null || !attachment.filename) continue; + keys.push(makeAttachmentCdnKey(channelId, attachment.attachment_id, String(attachment.filename))); + } + } + for (const asset of listReportProfileSnapshotAssets(report.reportedProfileSnapshot)) { + if (asset.key) { + keys.push(asset.key); + } + } + return keys; +} + +export async function forEachStoredReport( + reportRepository: IReportRepository, + visit: (report: IARSubmission) => void, + pageSize = SCAN_PAGE_SIZE, +): Promise { + let cursor: ReportID | undefined; + while (true) { + const page = await reportRepository.listAllReportsPaginated(pageSize, cursor); + for (const report of page) { + visit(report); + } + if (page.length < pageSize) return; + cursor = page[page.length - 1]!.reportId; + } +} + +async function storedObjectKeys(storageService: IStorageService, report: IARSubmission): Promise> { + const keys = new Set(referencedObjectKeys(report)); + const listed = await storageService.listObjects({ + bucket: Config.s3.buckets.reports, + prefix: `reports/${report.reportId}/profile/`, + }); + for (const object of listed) { + keys.add(object.key); + } + return keys; +} + +export async function deleteReportWithEvidence( + deps: ReportDeletionDeps, + report: IARSubmission, + ledger: ReportEvidenceLedger, + options: {now: Date; dryRun: boolean}, +): Promise { + const {reportRepository, storageService, reportSearchService} = deps; + const result: ReportDeletionResult = {objectsDeleted: 0, sharedObjectsKept: 0}; + for (const key of await storedObjectKeys(storageService, report)) { + if (ledger.retainedKeys.has(key)) { + result.sharedObjectsKept++; + continue; + } + if (ledger.deletedKeys.has(key)) continue; + const metadata = await storageService.getObjectMetadata(Config.s3.buckets.reports, key); + if (metadata?.lastModified && metadata.lastModified.getTime() >= options.now.getTime()) { + ledger.retainedKeys.add(key); + result.sharedObjectsKept++; + continue; + } + if (!options.dryRun) { + await storageService.deleteObject(Config.s3.buckets.reports, key); + } + ledger.deletedKeys.add(key); + result.objectsDeleted++; + } + if (!options.dryRun) { + await reportSearchService?.deleteReport(report.reportId); + await reportRepository.deleteReport(report.reportId); + } + return result; +} + +async function loadDeletableReport( + reportRepository: IReportRepository, + reportId: ReportID, + now: Date, +): Promise { + const report = await reportRepository.getReport(reportId); + if (!report) { + throw new UnknownReportError(); + } + if (isReportUnderLegalHold(report, now)) { + throw new ReportUnderLegalHoldError(); + } + return report; +} + +export async function deleteReportNow( + deps: ReportDeletionDeps, + reportId: ReportID, + now: Date, +): Promise<{report: IARSubmission} & ReportDeletionResult> { + const {reportRepository} = deps; + await loadDeletableReport(reportRepository, reportId, now); + const retainedKeys = new Set(); + await forEachStoredReport(reportRepository, (other) => { + if (other.reportId === reportId) return; + for (const key of referencedObjectKeys(other)) { + retainedKeys.add(key); + } + }); + const report = await loadDeletableReport(reportRepository, reportId, now); + const result = await deleteReportWithEvidence( + deps, + report, + {retainedKeys, deletedKeys: new Set()}, + {now, dryRun: false}, + ); + return {report, ...result}; +} diff --git a/fluxer_api/src/api/report/ReportRepository.ts b/fluxer_api/src/api/report/ReportRepository.ts index 58719ad67..340dee54d 100644 --- a/fluxer_api/src/api/report/ReportRepository.ts +++ b/fluxer_api/src/api/report/ReportRepository.ts @@ -27,6 +27,7 @@ import { type IARSubmissionRow, type IReportRepository, ReportStatus, + ReportType, } from '@app/api/report/IReportRepository'; import { DSAReportEmailVerifications, @@ -196,11 +197,31 @@ export class ReportRepository implements IReportRepository { if (!report) { return; } + await this.releaseReporterReservation(report); await executeConditional( IARSubmissions.conditionalDeleteByPk({report_id: reportId}, {report_type: report.reportType}), ); } + private async releaseReporterReservation(report: IARSubmission): Promise { + const {reporterId, reportId, reportedAt} = report; + if (!reporterId) { + return; + } + const base = {reporter_id: reporterId, report_id: reportId, reported_at: reportedAt}; + if (report.reportType === ReportType.MESSAGE && report.reportedChannelId && report.reportedMessageId) { + await this.releaseMessageReportByReporter({ + ...base, + channel_id: report.reportedChannelId, + message_id: report.reportedMessageId, + }); + } else if (report.reportType === ReportType.USER && report.reportedUserId) { + await this.releaseUserReportByReporter({...base, reported_user_id: report.reportedUserId}); + } else if (report.reportType === ReportType.GUILD && report.reportedGuildId) { + await this.releaseGuildReportByReporter({...base, reported_guild_id: report.reportedGuildId}); + } + } + private mapRowToSubmission(row: IARSubmissionRow): IARSubmission { return { reportId: createReportID(row.report_id), diff --git a/fluxer_api/src/api/worker/tasks/ExpireReportSnapshots.ts b/fluxer_api/src/api/worker/tasks/ExpireReportSnapshots.ts index 115e9cff6..75499f425 100644 --- a/fluxer_api/src/api/worker/tasks/ExpireReportSnapshots.ts +++ b/fluxer_api/src/api/worker/tasks/ExpireReportSnapshots.ts @@ -2,31 +2,29 @@ import type {ReportID} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; -import {makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers'; -import type {IStorageService} from '@app/api/infrastructure/IStorageService'; import {Logger} from '@app/api/Logger'; -import type {IARSubmission, IReportRepository} from '@app/api/report/IReportRepository'; +import {type IARSubmission, ReportStatus} from '@app/api/report/IReportRepository'; +import { + deleteReportWithEvidence, + forEachStoredReport, + isReportUnderLegalHold, + type ReportDeletionDeps, + referencedObjectKeys, +} from '@app/api/report/ReportDeletion'; import {getReportSearchService} from '@app/api/SearchFactory'; -import type {IReportSearchService} from '@app/api/search/IReportSearchService'; -import {readOptionalEnv} from '@app/api/utils/IntegerOptions'; import {getWorkerDependencies} from '@app/api/worker/WorkerContext'; -import {listReportProfileSnapshotAssets} from '@fluxer/schema/src/domains/report/ReportProfileSnapshotSchemas'; import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask'; import {ms} from 'itty-time'; -const REPORT_RETENTION_DAYS = 365; -const REPORT_RETENTION_MS = REPORT_RETENTION_DAYS * ms('1 day'); -const DEFAULT_SCAN_PAGE_SIZE = 500; - -export interface ReportRetentionDeps { - reportRepository: IReportRepository; - storageService: IStorageService; - reportSearchService: IReportSearchService | null; +export interface ReportRetentionPolicy { + retentionDays: number; + resolvedRetentionDays: number | null; } export interface ReportRetentionOptions { now: Date; dryRun: boolean; + policy: ReportRetentionPolicy; pageSize?: number; } @@ -43,63 +41,36 @@ export interface ReportRetentionSummary { type ReportRetentionState = 'expired' | 'held' | 'retained'; -export function resolveReportRetentionDryRun(raw: string | undefined): boolean { - const value = raw?.trim().toLowerCase(); - return value !== 'false' && value !== '0'; +function reportExpiresAt(report: IARSubmission, policy: ReportRetentionPolicy): number { + const reportedAt = report.reportedAt instanceof Date ? report.reportedAt.getTime() : Number.NaN; + const byAge = reportedAt + policy.retentionDays * ms('1 day'); + if (policy.resolvedRetentionDays === null || report.status !== ReportStatus.RESOLVED) { + return byAge; + } + const resolvedAt = report.resolvedAt instanceof Date ? report.resolvedAt.getTime() : Number.NaN; + if (Number.isNaN(resolvedAt)) { + return byAge; + } + return Math.min(byAge, resolvedAt + policy.resolvedRetentionDays * ms('1 day')); } -function reportRetentionState(report: IARSubmission, now: Date): ReportRetentionState { - const reportedAt = report.reportedAt instanceof Date ? report.reportedAt.getTime() : Number.NaN; - if (Number.isNaN(reportedAt) || reportedAt > now.getTime() - REPORT_RETENTION_MS) { +function reportRetentionState(report: IARSubmission, now: Date, policy: ReportRetentionPolicy): ReportRetentionState { + const expiresAt = reportExpiresAt(report, policy); + if (Number.isNaN(expiresAt) || expiresAt > now.getTime()) { return 'retained'; } - if (report.legalHoldUntil && report.legalHoldUntil.getTime() > now.getTime()) { + if (isReportUnderLegalHold(report, now)) { return 'held'; } return 'expired'; } -function reportProfilePrefix(reportId: ReportID): string { - return `reports/${reportId}/profile/`; -} - -function referencedObjectKeys(report: IARSubmission): Array { - const keys: Array = []; - for (const message of report.messageContext ?? []) { - const channelId = message.channelId ?? report.reportedChannelId; - if (!channelId) continue; - for (const attachment of message.attachments) { - if (attachment.attachment_id == null || !attachment.filename) continue; - keys.push(makeAttachmentCdnKey(channelId, attachment.attachment_id, String(attachment.filename))); - } - } - for (const asset of listReportProfileSnapshotAssets(report.reportedProfileSnapshot)) { - if (asset.key) { - keys.push(asset.key); - } - } - return keys; -} - -async function storedObjectKeys(storageService: IStorageService, report: IARSubmission): Promise> { - const keys = new Set(referencedObjectKeys(report)); - const listed = await storageService.listObjects({ - bucket: Config.s3.buckets.reports, - prefix: reportProfilePrefix(report.reportId), - }); - for (const object of listed) { - keys.add(object.key); - } - return keys; -} - export async function processReportRetention( - deps: ReportRetentionDeps, + deps: ReportDeletionDeps, options: ReportRetentionOptions, ): Promise { - const {reportRepository, storageService, reportSearchService} = deps; - const {now, dryRun} = options; - const pageSize = options.pageSize ?? DEFAULT_SCAN_PAGE_SIZE; + const {reportRepository} = deps; + const {now, dryRun, policy} = options; const summary: ReportRetentionSummary = { dryRun, scanned: 0, @@ -112,15 +83,14 @@ export async function processReportRetention( }; const retainedKeys = new Set(); const expiredIds: Array = []; - let cursor: ReportID | undefined; - while (true) { - const page = await reportRepository.listAllReportsPaginated(pageSize, cursor); - for (const report of page) { + await forEachStoredReport( + reportRepository, + (report) => { summary.scanned++; - const state = reportRetentionState(report, now); + const state = reportRetentionState(report, now, policy); if (state === 'expired') { expiredIds.push(report.reportId); - continue; + return; } if (state === 'held') { summary.held++; @@ -128,59 +98,47 @@ export async function processReportRetention( for (const key of referencedObjectKeys(report)) { retainedKeys.add(key); } - } - if (page.length < pageSize) break; - cursor = page[page.length - 1]!.reportId; - } + }, + options.pageSize, + ); summary.expired = expiredIds.length; - const deletedKeys = new Set(); + const ledger = {retainedKeys, deletedKeys: new Set()}; for (const reportId of expiredIds) { try { const report = await reportRepository.getReport(reportId); if (!report) continue; - if (reportRetentionState(report, now) !== 'expired') { + if (reportRetentionState(report, now, policy) !== 'expired') { for (const key of referencedObjectKeys(report)) { retainedKeys.add(key); } continue; } - for (const key of await storedObjectKeys(storageService, report)) { - if (retainedKeys.has(key)) { - summary.sharedObjectsKept++; - continue; - } - if (deletedKeys.has(key)) continue; - const metadata = await storageService.getObjectMetadata(Config.s3.buckets.reports, key); - if (metadata?.lastModified && metadata.lastModified.getTime() >= now.getTime()) { - retainedKeys.add(key); - summary.sharedObjectsKept++; - continue; - } - if (!dryRun) { - await storageService.deleteObject(Config.s3.buckets.reports, key); - } - deletedKeys.add(key); - summary.objectsDeleted++; - } - if (!dryRun) { - await reportSearchService?.deleteReport(reportId); - await reportRepository.deleteReport(reportId); - } + const result = await deleteReportWithEvidence(deps, report, ledger, {now, dryRun}); + summary.objectsDeleted += result.objectsDeleted; + summary.sharedObjectsKept += result.sharedObjectsKept; summary.deleted++; } catch (error) { summary.failed++; Logger.error({error, reportId: reportId.toString()}, 'Failed to delete an expired report'); } } - Logger.info({...summary, retentionDays: REPORT_RETENTION_DAYS}, 'Processed report retention'); + Logger.info( + { + ...summary, + retentionDays: policy.retentionDays, + resolvedRetentionDays: policy.resolvedRetentionDays, + }, + 'Processed report retention', + ); return summary; } const expireReportSnapshots: WorkerTaskHandler = async () => { const {reportRepository, storageService} = getWorkerDependencies(); + const {days, resolvedDays, dryRun} = Config.reportRetention; await processReportRetention( {reportRepository, storageService, reportSearchService: getReportSearchService()}, - {now: new Date(), dryRun: resolveReportRetentionDryRun(readOptionalEnv('FLUXER_REPORT_RETENTION_DRY_RUN'))}, + {now: new Date(), dryRun, policy: {retentionDays: days, resolvedRetentionDays: resolvedDays}}, ); }; diff --git a/fluxer_api/src/api/worker/tests/ExpireReportSnapshots.test.ts b/fluxer_api/src/api/worker/tests/ExpireReportSnapshots.test.ts index dab6b8602..dce264875 100644 --- a/fluxer_api/src/api/worker/tests/ExpireReportSnapshots.test.ts +++ b/fluxer_api/src/api/worker/tests/ExpireReportSnapshots.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {createAttachmentID, createChannelID, createReportID, type ReportID} from '@app/api/BrandedTypes'; +import {createAttachmentID, createChannelID, createReportID, createUserID, type ReportID} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; import {makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers'; import type {MessageAttachment} from '@app/api/database/types/MessageTypes'; @@ -10,22 +10,28 @@ import { type IARSubmissionRow, } from '@app/api/database/types/ReportTypes'; import type {IARSubmission} from '@app/api/report/IReportRepository'; +import type {ReportDeletionDeps} from '@app/api/report/ReportDeletion'; import {ReportRepository} from '@app/api/report/ReportRepository'; import type {IReportSearchService} from '@app/api/search/IReportSearchService'; import {MockStorageService} from '@app/api/test/mocks/MockStorageService'; +import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; import {InMemorySearchProvider} from '@app/api/test/search/InMemorySearchProvider'; -import { +import expireReportSnapshots, { processReportRetention, - type ReportRetentionDeps, - resolveReportRetentionDryRun, + type ReportRetentionOptions, + type ReportRetentionPolicy, } from '@app/api/worker/tasks/ExpireReportSnapshots'; +import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext'; import {serializeReportProfileSnapshot} from '@fluxer/schema/src/domains/report/ReportProfileSnapshotSchemas'; +import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask'; import {ms} from 'itty-time'; -import {beforeEach, describe, expect, it} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; const NOW = new Date('2027-06-01T12:00:00.000Z'); const DAY_MS = ms('1 day'); const REPORTS_BUCKET = Config.s3.buckets.reports; +const WORKER_HELPERS = {logger: new NoopLogger()} as unknown as WorkerTaskHelpers; +const DEFAULT_POLICY: ReportRetentionPolicy = {retentionDays: 365, resolvedRetentionDays: null}; let sequence = 1_480_000_000_000_000_000n; function nextId(): bigint { @@ -92,6 +98,7 @@ interface SeedOptions { profile?: boolean; legalHoldUntil?: Date | null; status?: number; + resolvedDaysAgo?: number; snapshotJson?: string; } @@ -113,7 +120,7 @@ describe('report retention', () => { let repository: ReportRepository; let storage: MockStorageService; let search: IReportSearchService; - let deps: ReportRetentionDeps; + let deps: ReportDeletionDeps; beforeEach(() => { repository = new ReportRepository(); @@ -158,7 +165,8 @@ describe('report retention', () => { ...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])), report_id: id, reported_at: daysAgo(options.ageDays), - status: options.status ?? 0, + status: options.status ?? (options.resolvedDaysAgo === undefined ? 0 : 1), + resolved_at: options.resolvedDaysAgo === undefined ? null : daysAgo(options.resolvedDaysAgo), report_type: 0, category: 'other', reported_channel_id: options.attachment?.channelId ?? null, @@ -195,7 +203,7 @@ describe('report retention', () => { const expired = await seed({ageDays: 365, attachment: newAttachment(), profile: true}); const kept = await seed({ageDays: 364, attachment: newAttachment(), profile: true}); - const summary = await processReportRetention(deps, {now: NOW, dryRun: false}); + const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY}); await expectGone(expired); expect(storage.hasObject(REPORTS_BUCKET, expired.attachmentKey!)).toBe(false); @@ -216,7 +224,7 @@ describe('report retention', () => { const pending = await seed({ageDays: 400, status: 0}); const resolved = await seed({ageDays: 400, status: 1}); - await processReportRetention(deps, {now: NOW, dryRun: false}); + await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY}); await expectGone(pending); await expectGone(resolved); @@ -227,7 +235,7 @@ describe('report retention', () => { const expired = await seed({ageDays: 500, attachment: shared}); const retained = await seed({ageDays: 10, attachment: shared}); - const summary = await processReportRetention(deps, {now: NOW, dryRun: false}); + const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY}); await expectGone(expired); await expectKept(retained); @@ -241,7 +249,7 @@ describe('report retention', () => { const first = await seed({ageDays: 500, attachment: shared}); const second = await seed({ageDays: 450, attachment: shared}); - const summary = await processReportRetention(deps, {now: NOW, dryRun: false}); + const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY}); await expectGone(first); await expectGone(second); @@ -261,7 +269,7 @@ describe('report retention', () => { const holdEnded = await seed({ageDays: 500, profile: true, legalHoldUntil: daysAgo(1)}); const sharingWithHeld = await seed({ageDays: 500, attachment: shared}); - const summary = await processReportRetention(deps, {now: NOW, dryRun: false}); + const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY}); await expectKept(held); expect((await repository.getReport(held.reportId))?.legalHoldReason).toBe('Preservation request'); @@ -275,7 +283,7 @@ describe('report retention', () => { const expired = await seed({ageDays: 366, attachment: newAttachment(), profile: true}); const kept = await seed({ageDays: 1, profile: true}); - const summary = await processReportRetention(deps, {now: NOW, dryRun: true}); + const summary = await processReportRetention(deps, {now: NOW, dryRun: true, policy: DEFAULT_POLICY}); await expectKept(expired); await expectKept(kept); @@ -305,7 +313,10 @@ describe('report retention', () => { }; await expect( - processReportRetention({...deps, reportRepository: failingRepository}, {now: NOW, dryRun: false, pageSize: 2}), + processReportRetention( + {...deps, reportRepository: failingRepository}, + {now: NOW, dryRun: false, policy: DEFAULT_POLICY, pageSize: 2}, + ), ).rejects.toThrow('read timeout'); await expectKept(expired); @@ -321,7 +332,7 @@ describe('report retention', () => { kept.push(await seed({ageDays: index, profile: true})); } - const summary = await processReportRetention(deps, {now: NOW, dryRun: false, pageSize: 3}); + const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY, pageSize: 3}); expect(summary).toMatchObject({scanned: 14, expired: 7, deleted: 7, objectsDeleted: 7, failed: 0}); for (const report of expired) await expectGone(report); @@ -332,14 +343,14 @@ describe('report retention', () => { const expired = await seed({ageDays: 400, attachment: newAttachment(), profile: true}); storage.configure({shouldFailDelete: true}); - const failed = await processReportRetention(deps, {now: NOW, dryRun: false}); + const failed = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY}); expect(failed).toMatchObject({expired: 1, deleted: 0, failed: 1}); expect(await repository.getReport(expired.reportId)).not.toBeNull(); expect(await indexedIds()).toContain(expired.reportId.toString()); storage.configure({shouldFailDelete: false}); - const retried = await processReportRetention(deps, {now: NOW, dryRun: false}); + const retried = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY}); expect(retried).toMatchObject({expired: 1, deleted: 1, failed: 0}); await expectGone(expired); @@ -355,7 +366,7 @@ describe('report retention', () => { const summary = await processReportRetention( {...deps, reportSearchService: failingSearch}, - {now: NOW, dryRun: false}, + {now: NOW, dryRun: false, policy: DEFAULT_POLICY}, ); expect(summary).toMatchObject({deleted: 0, failed: 1}); @@ -365,7 +376,10 @@ describe('report retention', () => { it('deletes rows when search is not configured', async () => { const expired = await seed({ageDays: 400, profile: true}); - const summary = await processReportRetention({...deps, reportSearchService: null}, {now: NOW, dryRun: false}); + const summary = await processReportRetention( + {...deps, reportSearchService: null}, + {now: NOW, dryRun: false, policy: DEFAULT_POLICY}, + ); expect(summary).toMatchObject({deleted: 1, failed: 0}); expect(await repository.getReport(expired.reportId)).toBeNull(); @@ -394,7 +408,7 @@ describe('report retention', () => { const summary = await processReportRetention( {...deps, reportRepository: holdingRepository}, - {now: NOW, dryRun: false}, + {now: NOW, dryRun: false, policy: DEFAULT_POLICY}, ); await expectKept(first); @@ -417,7 +431,7 @@ describe('report retention', () => { const summary = await processReportRetention( {...deps, storageService: rewritingStorage}, - {now: NOW, dryRun: false}, + {now: NOW, dryRun: false, policy: DEFAULT_POLICY}, ); await expectGone(expired); @@ -428,22 +442,156 @@ describe('report retention', () => { it('deletes profile images stored for a report whose snapshot cannot be read', async () => { const expired = await seed({ageDays: 400, profile: true, snapshotJson: '{"not":"a snapshot"'}); - const summary = await processReportRetention(deps, {now: NOW, dryRun: false}); + const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY}); expect(summary).toMatchObject({deleted: 1, objectsDeleted: 1}); await expectGone(expired); }); - it.each([ - [undefined, true], - ['', true], - ['true', true], - ['1', true], - ['yes', true], - ['false', false], - ['FALSE', false], - [' 0 ', false], - ])('FLUXER_REPORT_RETENTION_DRY_RUN=%j gives a dry run of %s', (raw, expected) => { - expect(resolveReportRetentionDryRun(raw)).toBe(expected); + function run(options: Partial = {}) { + return processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY, ...options}); + } + + it('deletes earlier when the retention period is shorter', async () => { + const expired = await seed({ageDays: 30, profile: true}); + const kept = await seed({ageDays: 29, profile: true}); + + const summary = await run({policy: {retentionDays: 30, resolvedRetentionDays: null}}); + + await expectGone(expired); + await expectKept(kept); + expect(summary).toMatchObject({scanned: 2, expired: 1, deleted: 1}); + }); + + it('deletes a resolved report once the resolved-report period has passed since it was resolved', async () => { + const policy = {retentionDays: 365, resolvedRetentionDays: 30}; + const resolvedLongAgo = await seed({ageDays: 100, resolvedDaysAgo: 30, attachment: newAttachment()}); + const resolvedRecently = await seed({ageDays: 100, resolvedDaysAgo: 29, profile: true}); + const pending = await seed({ageDays: 100, profile: true}); + const resolvedWithoutTime = await seed({ageDays: 100, status: 1, profile: true}); + + const summary = await run({policy}); + + await expectGone(resolvedLongAgo); + expect(storage.hasObject(REPORTS_BUCKET, resolvedLongAgo.attachmentKey!)).toBe(false); + await expectKept(resolvedRecently); + await expectKept(pending); + await expectKept(resolvedWithoutTime); + expect(summary).toMatchObject({scanned: 4, expired: 1, deleted: 1, held: 0}); + }); + + it('applies whichever rule ends first', async () => { + const policy = {retentionDays: 100, resolvedRetentionDays: 300}; + const byAge = await seed({ageDays: 100, resolvedDaysAgo: 10}); + const neither = await seed({ageDays: 99, resolvedDaysAgo: 90}); + + await run({policy}); + + await expectGone(byAge); + await expectKept(neither); + }); + + it('keeps a held resolved report past the resolved-report period and keeps its evidence', async () => { + const shared = newAttachment(); + const held = await seed({ + ageDays: 50, + resolvedDaysAgo: 40, + attachment: shared, + profile: true, + legalHoldUntil: new Date(NOW.getTime() + DAY_MS), + }); + const sharingWithHeld = await seed({ageDays: 50, resolvedDaysAgo: 40, attachment: shared}); + + const summary = await run({policy: {retentionDays: 365, resolvedRetentionDays: 7}}); + + await expectKept(held); + await expectGone(sharingWithHeld); + expect(storage.hasObject(REPORTS_BUCKET, attachmentKey(shared))).toBe(true); + expect(summary).toMatchObject({expired: 1, held: 1, deleted: 1, sharedObjectsKept: 1}); + }); + + it('frees the reporter to report the same account again once the report is deleted', async () => { + const reporterId = nextId(); + const reportedUserId = nextId(); + const reportId = createReportID(nextId()); + await repository.createReport({ + ...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])), + report_id: BigInt(reportId), + reporter_id: reporterId, + reported_user_id: reportedUserId, + reported_at: daysAgo(400), + status: 0, + report_type: 1, + category: 'harassment', + } as IARSubmissionRow); + const reservation = { + reporter_id: createUserID(reporterId), + reported_user_id: createUserID(reportedUserId), + report_id: reportId, + reported_at: daysAgo(400), + }; + expect(await repository.reserveUserReportByReporter(reservation)).toBe(true); + + await run(); + + expect(await repository.getReport(reportId)).toBeNull(); + expect(await repository.reserveUserReportByReporter({...reservation, report_id: createReportID(nextId())})).toBe( + true, + ); + }); + + describe('the daily task', () => { + const configured = {...Config.reportRetention}; + + beforeEach(() => { + setWorkerDependenciesForTest({reportRepository: repository, storageService: storage}); + }); + + afterEach(() => { + vi.useRealTimers(); + Object.assign(Config.reportRetention, configured); + clearWorkerDependencies(); + }); + + async function runDailyTask() { + vi.useFakeTimers({now: NOW, toFake: ['Date']}); + await expireReportSnapshots({}, WORKER_HELPERS); + } + + it('deletes for real with the default settings', async () => { + expect(configured).toEqual({days: 365, resolvedDays: null, dryRun: false}); + const expired = await seed({ageDays: 366, attachment: newAttachment(), profile: true}); + const kept = await seed({ageDays: 364, profile: true}); + + await runDailyTask(); + + expect(await repository.getReport(expired.reportId)).toBeNull(); + expect(storage.hasObject(REPORTS_BUCKET, expired.attachmentKey!)).toBe(false); + expect(storage.hasObject(REPORTS_BUCKET, expired.profileKey!)).toBe(false); + expect(await repository.getReport(kept.reportId)).not.toBeNull(); + }); + + it('only counts what it would delete when FLUXER_REPORT_RETENTION_DRY_RUN is true', async () => { + Object.assign(Config.reportRetention, {dryRun: true}); + const expired = await seed({ageDays: 366, attachment: newAttachment(), profile: true}); + + await runDailyTask(); + + expect(await repository.getReport(expired.reportId)).not.toBeNull(); + expect(storage.deleteObjectSpy).not.toHaveBeenCalled(); + }); + + it('reads both day settings from the config', async () => { + Object.assign(Config.reportRetention, {days: 60, resolvedDays: 5}); + const byAge = await seed({ageDays: 60}); + const byResolution = await seed({ageDays: 20, resolvedDaysAgo: 5}); + const kept = await seed({ageDays: 20, resolvedDaysAgo: 4}); + + await runDailyTask(); + + expect(await repository.getReport(byAge.reportId)).toBeNull(); + expect(await repository.getReport(byResolution.reportId)).toBeNull(); + expect(await repository.getReport(kept.reportId)).not.toBeNull(); + }); }); }); diff --git a/fluxer_docs/src/content/docs/admin-api/api-keys.mdx b/fluxer_docs/src/content/docs/admin-api/api-keys.mdx index 7d55739f8..ab934e16c 100644 --- a/fluxer_docs/src/content/docs/admin-api/api-keys.mdx +++ b/fluxer_docs/src/content/docs/admin-api/api-keys.mdx @@ -140,7 +140,7 @@ The acting credential must already have every value in `acls`, unless it has `*` | --- | --- | --- | | name1 | string | The name given to the key (1-100 characters) | | expires_in_days?2 | integer | The number of days until the key expires (1-365) | -| acls3 | array[string] | The [ACLs](/admin-api/#acl-registry) stored on the key, each a registry value (at most 105) | +| acls3 | array[string] | The [ACLs](/admin-api/#acl-registry) stored on the key, each a registry value (at most 106) | 1 A value that is empty after trimming is rejected, so whitespace alone is not a name @@ -218,7 +218,7 @@ An update never rotates the credential, and no field on this route changes the e | Field | Type | Description | | --- | --- | --- | | name?1 | string | The replacement name for the key (1-100 characters) | -| acls?2 | array[string] | The complete replacement set of [ACLs](/admin-api/#acl-registry), each a registry value (at most 105) | +| acls?2 | array[string] | The complete replacement set of [ACLs](/admin-api/#acl-registry), each a registry value (at most 106) | 1 A value that is empty after trimming is rejected diff --git a/fluxer_docs/src/content/docs/admin-api/index.mdx b/fluxer_docs/src/content/docs/admin-api/index.mdx index 100cc45b4..4b666a1c6 100644 --- a/fluxer_docs/src/content/docs/admin-api/index.mdx +++ b/fluxer_docs/src/content/docs/admin-api/index.mdx @@ -61,7 +61,7 @@ A body with none of those fields resolves to no ACL at all and applies no change Fluxer checks each ACL an Admin grants against the ACLs that Admin holds. [Set user ACLs](/admin-api/users/#set-user-acls) and [Create Admin API key](/admin-api/api-keys/#create-admin-api-key) both refuse to write an ACL the acting Admin does not itself hold, with 403 `MISSING_ACL`. A wildcard holder is exempt. Set user ACLs also refuses the acting Admin's own account with 403 `ACCESS_DENIED`, and it looks up the target account before it checks the granted ACLs, so an unknown ID fails first with 404 `UNKNOWN_USER`. -[Set user ACLs](/admin-api/users/#set-user-acls), [Create Admin API key](/admin-api/api-keys/#create-admin-api-key), and [Update Admin API key](/admin-api/api-keys/#update-admin-api-key) each accept at most 105 ACLs and validate every entry against the registry, so a value outside it returns 400 `INVALID_FORM_BODY`. +[Set user ACLs](/admin-api/users/#set-user-acls), [Create Admin API key](/admin-api/api-keys/#create-admin-api-key), and [Update Admin API key](/admin-api/api-keys/#update-admin-api-key) each accept at most 106 ACLs and validate every entry against the registry, so a value outside it returns 400 `INVALID_FORM_BODY`. :::caution[`*` satisfies every present and future ACL] A key whose owning account holds `*` skips the owner check. An Admin holding `*` can grant any ACL. @@ -348,6 +348,7 @@ An entry with any other action has `access` set to `write`. | delete_all_user_messages_dry_run | The same operation was run with `dry_run` set, so no message was deleted | | delete_guild | A guild was deleted | | delete_message | One message was deleted | +| delete_report | A report was deleted with its stored evidence | | delete_voice_region | A voice region was deleted | | delete_voice_server | A registered voice server was deleted | | delete_webauthn_credential | A WebAuthn credential was removed from an account | @@ -481,7 +482,7 @@ Returns every Admin permission string the instance recognises, in [ACL registry] | Field | Type | Description | | --- | --- | --- | -| acls1 | array[string] | The permission strings the Admin API recognises (at most 105 entries) | +| acls1 | array[string] | The permission strings the Admin API recognises (at most 106 entries) | 1 The response is the registry itself and does not vary with the caller's own ACL set @@ -576,6 +577,7 @@ The registry is returned in this order by [List ACLs](#list-acls). A value outsi | message:delete | Deletes one message | | message:lookup | Reads, browses, and searches [messages](/admin-api/messages/) through the Admin API | | message:shred | Queues an explicit message shred job and reads its state | +| report:delete | Deletes a [report](/admin-api/reports/#delete-report) with its stored evidence | | report:resolve | Updates and resolves [reports](/admin-api/reports/), and places legal holds on them | | report:view | Reads and searches reports | | report:view:reporter_pii6 | Unredacts the contact fields a reporter supplied | diff --git a/fluxer_docs/src/content/docs/admin-api/reports.mdx b/fluxer_docs/src/content/docs/admin-api/reports.mdx index a7cdb594f..cc1318916 100644 --- a/fluxer_docs/src/content/docs/admin-api/reports.mdx +++ b/fluxer_docs/src/content/docs/admin-api/reports.mdx @@ -1,12 +1,12 @@ --- # SPDX-License-Identifier: AGPL-3.0-or-later title: Admin reports -description: Report listing and search, captured evidence, report resolution, and report retention. +description: Report listing and search, captured evidence, report resolution, report deletion, and report retention. --- import RouteHeader from '@/components/RouteHeader.astro'; -A report is one complaint filed against a message, an account, or a guild, together with the evidence captured when it was filed. These routes list and search reports, return that evidence, resolve a report with a comment shown to the reporter, and hold a report past its retention period. +A report is one complaint filed against a message, an account, or a guild, together with the evidence captured when it was filed. These routes list and search reports, return that evidence, resolve a report with a comment shown to the reporter, hold a report past its retention period, and delete a report before it. Report submission belongs to the public [Reports](/http-api/reports/) resource, which defines the [report categories](/http-api/reports/#report-categories) and the [report reasons](/http-api/reports/#report-reasons) a report is filed under. @@ -22,7 +22,7 @@ Fluxer reads the tag, username, display name, and discriminator of both accounts ## Admin report object -A report is written once, when the reporter files it. [Update report](#update-report) moves it from PENDING to RESOLVED with an optional comment. [Schedule user deletion](/admin-api/users/#schedule-user-deletion) for one of the abuse reasons listed there also resolves the pending reports against the deleted account, with no public comment, and sends each reporter the generic notice [Update report](#update-report) describes. A report in the `child_safety`, `underage_user`, or `self_harm` category is never resolved this way and stays PENDING. A report is deleted at the end of its [retention period](#report-retention). +A report is written once, when the reporter files it. [Update report](#update-report) moves it from PENDING to RESOLVED with an optional comment. [Schedule user deletion](/admin-api/users/#schedule-user-deletion) for one of the abuse reasons listed there also resolves the pending reports against the deleted account, with no public comment, and sends each reporter the generic notice [Update report](#update-report) describes. A report in the `child_safety`, `underage_user`, or `self_harm` category is never resolved this way and stays PENDING. A report is deleted at the end of its [retention period](#report-retention), or earlier through [Delete report](#delete-report). [List reports](#list-reports) returns this object without message context. [Get report](#get-report) returns the [Admin report detail object](#admin-report-detail-object), which extends it. @@ -519,12 +519,14 @@ A report resolution is the reduced view [Update report](#update-report) returns. ## Report retention -Fluxer deletes a report 365 days after it was filed, whether it is pending or resolved, together with its stored evidence: the attachment copies, the profile snapshot images, and its search index entry. A report with a [legal hold](#set-report-legal-hold) that ends later is kept until the next run after the hold ends. An evidence copy that a retained report also uses is kept for that report. +Fluxer deletes a report when its retention period ends, whether it is pending or resolved, together with its stored evidence: the attachment copies, the profile snapshot images, and its search index entry. The reporter's duplicate-report reservation for it goes too, so the reporter can report the same message, account or guild again. -The deletion runs once a day in `worker`. Each run logs one `Processed report retention` line with the counts it found and deleted. A report that fails to delete keeps its record and is retried on the next run. +The retention period is 365 days from when the report was filed, or the number of days in `FLUXER_REPORT_RETENTION_DAYS`. With `FLUXER_RESOLVED_REPORT_RETENTION_DAYS` set, a resolved report is deleted that many days after `resolved_at` when that comes first. A report with a [legal hold](#set-report-legal-hold) that ends later is kept until the next run after the hold ends. An evidence copy that a retained report also uses is kept for that report. -:::caution[Retention starts in dry-run mode] -Until `FLUXER_REPORT_RETENTION_DRY_RUN` is set to `false` on `worker`, each run only counts what it would delete and deletes nothing. The [operator configuration](/operator/configuration/#moderation-and-abuse) describes the setting. +The deletion runs once a day in `worker`. Each run logs one `Processed report retention` line with the counts it found and deleted and the two periods it used. A report that fails to delete keeps its record and is retried on the next run. [Delete report](#delete-report) deletes one report straight away by the same rules. + +:::caution[Deleted evidence cannot be restored] +Retention deletes for real unless `FLUXER_REPORT_RETENTION_DRY_RUN` is `true` on `worker`. In a dry run each run only counts what it would delete and deletes nothing. The [operator configuration](/operator/configuration/#moderation-and-abuse) describes the three settings. ::: ## List reports @@ -714,7 +716,7 @@ A reporter who does not already have the system channel open receives [Channel C Places or clears a legal hold on one report and returns a [report legal hold](#report-legal-hold-object) object. Requires `report:resolve`. -A held report is kept past the end of its [retention period](#report-retention) until the hold ends. A hold can be placed on a pending or a resolved report, and a new hold replaces the previous one. +A held report is kept past the end of its [retention period](#report-retention) until the hold ends, and [Delete report](#delete-report) refuses it. A hold can be placed on a pending or a resolved report, and a new hold replaces the previous one. ### Path parameters @@ -748,3 +750,37 @@ Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) wit ### Rate limit 200 requests per minute for each authenticated user, on the `admin:lookup` bucket. + +## Delete report + + + +Deletes one report straight away. Requires `report:delete`, which `report:resolve` does not include. + +This is the deletion [report retention](#report-retention) runs, without waiting for the retention period. It removes the report record with its captured message context and profile snapshot, its stored evidence copies, its search index entry, and the reporter's duplicate-report reservation for it. An evidence copy that another report also uses is kept for that report. A pending or a resolved report can be deleted, and the reporter is not told. + +:::caution[Deletion is permanent] +Deleted evidence cannot be restored. A report with a legal hold that has not ended is refused. Clear the hold first when the report should go. +::: + +### Path parameters + +| Field | Type | Description | +| --- | --- | --- | +| report_id | snowflake | The ID of the report | + +### Response + +| Status | Body | Condition | +| --- | --- | --- | +| 204 | empty | The report was deleted | +| 404 | [error response](/admin-api/#error-response) | Report does not exist and the request returns `UNKNOWN_REPORT` | +| 409 | [error response](/admin-api/#error-response) | The report has a legal hold that ends later and the request returns `REPORT_UNDER_LEGAL_HOLD` | + +### Side effects + +Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `delete_report`, target type `report`, and the report ID as target ID. The metadata keys are `report_id`, `report_type`, `status`, `objects_deleted` for the evidence copies removed, and `shared_objects_kept` for the copies kept because another report uses them. A refused request records nothing. + +### Rate limit + +200 requests per minute for each authenticated user, on the `admin:lookup` bucket. diff --git a/fluxer_docs/src/content/docs/admin-api/users.mdx b/fluxer_docs/src/content/docs/admin-api/users.mdx index c70c15648..57286bc3c 100644 --- a/fluxer_docs/src/content/docs/admin-api/users.mdx +++ b/fluxer_docs/src/content/docs/admin-api/users.mdx @@ -59,7 +59,7 @@ When the caller lacks `user:view:email`, `user:view:dob`, or `user:view:ip`, Flu | deletion_audit_log_reason | ?string | The private reason stored with the pending deletion, and null without `audit_log:view` | | deletion_scheduled_by | ?snowflake | The ID of the account that scheduled the pending deletion, or null when it was not recorded | | deletion_scheduled_at | ?ISO8601 timestamp | The time the pending deletion was scheduled, or null when it was not recorded | -| acls7 | array[string] | Effective [Admin ACLs](/admin-api/#acl-registry), with at most 105 entries | +| acls7 | array[string] | Effective [Admin ACLs](/admin-api/#acl-registry), with at most 106 entries | | traits8 | array[string] | The free-form operator labels set on the account, with at most 100 entries | | has_totp9 | boolean | Whether a TOTP authenticator is registered | | authenticator_types9 12 | array[integer] | Registered [authenticator types](/http-api/users/#authenticator-types), with at most 10 entries | @@ -1148,7 +1148,7 @@ A path naming the acting account fails with 403 `ACCESS_DENIED` before the accou | Field | Type | Description | | --- | --- | --- | -| acls1 | array[string] | Replacement [Admin ACLs](/admin-api/#acl-registry), with at most 105 values | +| acls1 | array[string] | Replacement [Admin ACLs](/admin-api/#acl-registry), with at most 106 values | 1 Required. A value outside the [ACL registry](/admin-api/#acl-registry) fails body validation, and a repeated value is collapsed diff --git a/fluxer_docs/src/content/docs/http-api/errors.md b/fluxer_docs/src/content/docs/http-api/errors.md index 910904526..f6a0e4a36 100644 --- a/fluxer_docs/src/content/docs/http-api/errors.md +++ b/fluxer_docs/src/content/docs/http-api/errors.md @@ -882,6 +882,10 @@ Email verification is required for this action The report form changed while you were filling it in +### `REPORT_UNDER_LEGAL_HOLD` + +This report is under a legal hold and can't be deleted + ### `RESOURCE_LOCKED` This resource is being modified diff --git a/fluxer_docs/src/content/docs/http-api/reports.mdx b/fluxer_docs/src/content/docs/http-api/reports.mdx index c3332da12..36799b375 100644 --- a/fluxer_docs/src/content/docs/http-api/reports.mdx +++ b/fluxer_docs/src/content/docs/http-api/reports.mdx @@ -47,7 +47,7 @@ Every accepted submission writes one report and returns it as a [report object]( Two kinds of repeat are refused with 409 `CONFLICT`: -- The same reporter reporting the same message again within 365 days of the earlier report. +- The same reporter reporting the same message again within 365 days of the earlier report, unless that report was deleted. - The same reporter reporting the same user again within 24 hours of the earlier report. Nothing deduplicates a Digital Services Act notice. @@ -624,7 +624,7 @@ A reporter without `READ_MESSAGE_HISTORY` can report a message sent at or after | 409 | [error response](/http-api/#error-response) | The answers came from an outdated flow and do not fit the current one, returning `REPORT_FLOW_OUTDATED` | | 4091 | [error response](/http-api/#error-response) | The reporter already reported this message, returning `CONFLICT` | -1 The reporter, the channel, and the message form a submission key that expires 365 days after the report. A second report of the same message by the same reporter within that time is refused whichever answers it sends. A submission that fails or is rate limited releases the key +1 The reporter, the channel, and the message form a submission key that expires 365 days after the report. A second report of the same message by the same reporter within that time is refused whichever answers it sends. A submission that fails or is rate limited releases the key, and so does [deleting the report](/admin-api/reports/#delete-report) ### Side effects @@ -692,7 +692,7 @@ Fluxer checks the answers right after the claimed account and verified email che | 409 | [error response](/http-api/#error-response) | The answers came from an outdated flow and do not fit the current one, returning `REPORT_FLOW_OUTDATED` | | 4091 | [error response](/http-api/#error-response) | The reporter already reported this user within 24 hours, returning `CONFLICT` | -1 The reporter and the reported user form a submission key that expires 24 hours after the report. A submission that fails or is rate limited releases the key +1 The reporter and the reported user form a submission key that expires 24 hours after the report. A submission that fails or is rate limited releases the key, and so does [deleting the report](/admin-api/reports/#delete-report) ### Side effects diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index 11c89b5c9..a7b7e2a78 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -1024,9 +1024,17 @@ Defaults to the inverse of `FLUXER_SELF_HOSTED`. Off by default on a self-hosted With feeds off, Fluxer checks none of this data. The first worker start with feeds off removes the URL feed file and every `malware_bazaar` file-SHA ban that no Admin added. File-SHA bans added through the Admin API stay. Turning feeds back on downloads the URLs within six hours and the hashes within twelve. Compose forwards it from `.env`. +#### `FLUXER_REPORT_RETENTION_DAYS` + +Default `365`. Read by `worker`. The daily [report retention](/admin-api/reports/#report-retention) job deletes each report this many days after it was filed, pending or resolved, with its evidence copies and search entry, unless a legal hold is in place. Integer 1 to 36500, checked at startup. Compose forwards it from `.env`. + +#### `FLUXER_RESOLVED_REPORT_RETENTION_DAYS` + +Default unset, which leaves resolved reports to `FLUXER_REPORT_RETENTION_DAYS`. Read by `worker`. When set, a resolved report is deleted this many days after it was resolved, if that comes before the end of its retention period. Pending reports are not affected, and a legal hold still keeps a report. Integer 1 to 36500, checked at startup. Compose forwards it from `.env`. + #### `FLUXER_REPORT_RETENTION_DRY_RUN` -Default unset, which keeps the dry run. Read by `worker`. The daily [report retention](/admin-api/reports/#report-retention) job deletes reports 365 days after they were filed, with their evidence copies and search entries, unless a legal hold is in place. Until this is `false` or `0`, the job only logs what it would delete. Any other value, or no value, keeps the dry run. Check the counts in the `Processed report retention` log line before you turn deletion on, because deleted evidence cannot be restored. Compose forwards it from `.env`. +Default `false`, so report retention deletes for real. Read by `worker`. With `true`, each run only counts what it would delete and deletes nothing. `true` or `false`, checked at startup. Use it to check the counts in the `Processed report retention` log line before the first real run, because deleted evidence cannot be restored. Compose forwards it from `.env`. #### `FLUXER_BREACHED_PASSWORD_CHECK_ENABLED` diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index ce7920d80..f2ce94393 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -21,6 +21,8 @@ import { let cachedConfig: MasterConfig | null = null; +const MAX_REPORT_RETENTION_DAYS = 36_500; + const DEFAULT_PASSKEY_ORIGINS = [ 'https://fluxer.app', 'https://web.fluxer.app', @@ -271,6 +273,11 @@ function defaultConfig(): MasterConfig { attachment_decay_enabled: true, deletion_grace_period_hours: 336, inactivity_deletion_threshold_days: 365, + report_retention: { + days: 365, + resolved_days: null, + dry_run: false, + }, }; } @@ -436,6 +443,20 @@ function validateApiWorkerConfig(config: MasterConfig): void { } } +function validateReportRetentionConfig(config: MasterConfig): void { + const retention = config.report_retention; + assertIntegerInRange(retention.days, 'FLUXER_REPORT_RETENTION_DAYS', 1, MAX_REPORT_RETENTION_DAYS); + if (retention.resolved_days !== null) { + assertIntegerInRange( + retention.resolved_days, + 'FLUXER_RESOLVED_REPORT_RETENTION_DAYS', + 1, + MAX_REPORT_RETENTION_DAYS, + ); + } + assertBoolean(retention.dry_run, 'FLUXER_REPORT_RETENTION_DRY_RUN'); +} + function validateStorageChangeFeedConfig(config: MasterConfig): void { const feed = config.services.api?.storage_change_feed; if (!feed?.enabled) { @@ -594,6 +615,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig { validatePostgresConfig(config); validateApiWorkerConfig(config); validateStorageChangeFeedConfig(config); + validateReportRetentionConfig(config); validateCachePurgeConfig(config); validateStoreBillingConfig(config); validateReplyToEmail(config.integrations.email.reply_to_email); diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index 106e5e81e..79b4f5dc0 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -323,4 +323,9 @@ export interface MasterConfig { attachment_decay_enabled: boolean; deletion_grace_period_hours: number; inactivity_deletion_threshold_days: number; + report_retention: { + days: number; + resolved_days: number | null; + dry_run: boolean; + }; } diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index 0b12868bd..337d30c58 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -978,6 +978,60 @@ describe('ConfigLoader', () => { }); }); +describe('report retention settings', () => { + beforeEach(() => { + resetConfig(); + clearFluxerEnv(); + }); + + afterEach(() => { + resetConfig(); + vi.unstubAllEnvs(); + }); + + test('deletes reports for real after 365 days with no resolved-report rule by default', async () => { + stubMinimalEnv(); + const config = await loadConfig(); + expect(config.report_retention).toEqual({days: 365, resolved_days: null, dry_run: false}); + }); + + test('reads both day settings and the dry run switch from the environment', async () => { + stubMinimalEnv({ + FLUXER_REPORT_RETENTION_DAYS: '730', + FLUXER_RESOLVED_REPORT_RETENTION_DAYS: ' 90 ', + FLUXER_REPORT_RETENTION_DRY_RUN: 'TRUE', + }); + const config = await loadConfig(); + expect(config.report_retention).toEqual({days: 730, resolved_days: 90, dry_run: true}); + }); + + test('an empty resolved-report setting leaves the rule off', async () => { + stubMinimalEnv({FLUXER_RESOLVED_REPORT_RETENTION_DAYS: '', FLUXER_REPORT_RETENTION_DRY_RUN: 'false'}); + const config = await loadConfig(); + expect(config.report_retention).toEqual({days: 365, resolved_days: null, dry_run: false}); + }); + + test.each([ + ['FLUXER_REPORT_RETENTION_DAYS', '0', 'FLUXER_REPORT_RETENTION_DAYS must be an integer between 1 and 36500'], + ['FLUXER_REPORT_RETENTION_DAYS', '36501', 'FLUXER_REPORT_RETENTION_DAYS must be an integer between 1 and 36500'], + ['FLUXER_REPORT_RETENTION_DAYS', '1.5', 'FLUXER_REPORT_RETENTION_DAYS must be an integer, got "1.5"'], + [ + 'FLUXER_RESOLVED_REPORT_RETENTION_DAYS', + '0', + 'FLUXER_RESOLVED_REPORT_RETENTION_DAYS must be an integer between 1 and 36500', + ], + [ + 'FLUXER_RESOLVED_REPORT_RETENTION_DAYS', + '-30', + 'FLUXER_RESOLVED_REPORT_RETENTION_DAYS must be an integer between 1 and 36500', + ], + ['FLUXER_REPORT_RETENTION_DRY_RUN', '1', 'FLUXER_REPORT_RETENTION_DRY_RUN must be true or false'], + ])('refuses %s=%j', async (name, value, message) => { + stubMinimalEnv({[name]: value}); + await expect(loadConfig()).rejects.toThrow(message); + }); +}); + describe('FLUXER_PUBLIC_ORIGIN', () => { beforeEach(() => { resetConfig(); diff --git a/packages/config/src/__tests__/SelfHostingComposeEnvironment.test.ts b/packages/config/src/__tests__/SelfHostingComposeEnvironment.test.ts index 2c99139a6..a57ef8c2e 100644 --- a/packages/config/src/__tests__/SelfHostingComposeEnvironment.test.ts +++ b/packages/config/src/__tests__/SelfHostingComposeEnvironment.test.ts @@ -97,8 +97,11 @@ describe('the shipped compose stack forwards settings from .env', () => { expect(renamed).toEqual([]); }); - test('the worker is handed the report retention switch and gets no value when .env leaves it out', () => { - const name = 'FLUXER_REPORT_RETENTION_DRY_RUN'; + test.each([ + 'FLUXER_REPORT_RETENTION_DAYS', + 'FLUXER_RESOLVED_REPORT_RETENTION_DAYS', + 'FLUXER_REPORT_RETENTION_DRY_RUN', + ])('the worker is handed %s and gets no value when .env leaves it out', (name) => { expect(serviceEnvironment('worker')[name]).toBe(`\${${name}:-}`); }); }); diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index a4ebe7a51..77ff42805 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -291,6 +291,9 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_DISCOVERY_ENABLED: {path: ['discovery', 'enabled'], parse: parseBoolean}, FLUXER_DISCOVERY_MIN_MEMBER_COUNT: {path: ['discovery', 'min_member_count'], parse: parseInteger}, FLUXER_DELETION_GRACE_PERIOD_HOURS: {path: ['deletion_grace_period_hours'], parse: parseInteger}, + FLUXER_REPORT_RETENTION_DAYS: {path: ['report_retention', 'days'], parse: parseInteger}, + FLUXER_RESOLVED_REPORT_RETENTION_DAYS: {path: ['report_retention', 'resolved_days'], parse: parseInteger}, + FLUXER_REPORT_RETENTION_DRY_RUN: {path: ['report_retention', 'dry_run'], parse: parseBoolean}, FLUXER_RELAX_REGISTRATION_RATE_LIMITS: {path: ['dev', 'relax_registration_rate_limits'], parse: parseBoolean}, FLUXER_DISABLE_RATE_LIMITS: {path: ['dev', 'disable_rate_limits'], parse: parseBoolean}, FLUXER_TEST_MODE_ENABLED: {path: ['dev', 'test_mode_enabled'], parse: parseBoolean}, diff --git a/packages/constants/src/AdminACLs.ts b/packages/constants/src/AdminACLs.ts index a9f45173b..54ec6d1cd 100644 --- a/packages/constants/src/AdminACLs.ts +++ b/packages/constants/src/AdminACLs.ts @@ -71,6 +71,7 @@ export const AdminACLs = { MESSAGE_DELETE: 'message:delete', MESSAGE_LOOKUP: 'message:lookup', MESSAGE_SHRED: 'message:shred', + REPORT_DELETE: 'report:delete', REPORT_RESOLVE: 'report:resolve', REPORT_VIEW: 'report:view', REPORT_VIEW_REPORTER_PII: 'report:view:reporter_pii', diff --git a/packages/constants/src/ApiErrorCodes.ts b/packages/constants/src/ApiErrorCodes.ts index 78796e7aa..e76fbdade 100644 --- a/packages/constants/src/ApiErrorCodes.ts +++ b/packages/constants/src/ApiErrorCodes.ts @@ -193,6 +193,7 @@ export const APIErrorCodes = { REPORT_ALREADY_RESOLVED: 'REPORT_ALREADY_RESOLVED', REPORT_BANNED: 'REPORT_BANNED', REPORT_FLOW_OUTDATED: 'REPORT_FLOW_OUTDATED', + REPORT_UNDER_LEGAL_HOLD: 'REPORT_UNDER_LEGAL_HOLD', RESPONSE_VALIDATION_ERROR: 'RESPONSE_VALIDATION_ERROR', RESOURCE_LOCKED: 'RESOURCE_LOCKED', SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', diff --git a/packages/errors/src/domains/moderation/ReportUnderLegalHoldError.ts b/packages/errors/src/domains/moderation/ReportUnderLegalHoldError.ts new file mode 100644 index 000000000..70a08236e --- /dev/null +++ b/packages/errors/src/domains/moderation/ReportUnderLegalHoldError.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError'; + +export class ReportUnderLegalHoldError extends ConflictError { + constructor() { + super({ + code: APIErrorCodes.REPORT_UNDER_LEGAL_HOLD, + }); + } +} diff --git a/packages/errors/src/i18n/ErrorCodeMappings.ts b/packages/errors/src/i18n/ErrorCodeMappings.ts index 22f6338d3..1f0de25bb 100644 --- a/packages/errors/src/i18n/ErrorCodeMappings.ts +++ b/packages/errors/src/i18n/ErrorCodeMappings.ts @@ -239,6 +239,7 @@ export const ErrorCodeToI18nKey = { [APIErrorCodes.REPORT_ALREADY_RESOLVED]: 'moderation_and_reports.report_already_resolved', [APIErrorCodes.REPORT_BANNED]: 'moderation_and_reports.report_banned', [APIErrorCodes.REPORT_FLOW_OUTDATED]: 'moderation_and_reports.report_flow_outdated', + [APIErrorCodes.REPORT_UNDER_LEGAL_HOLD]: 'moderation_and_reports.report_under_legal_hold', [APIErrorCodes.RESPONSE_VALIDATION_ERROR]: 'admin_and_system.response_validation_failed', [APIErrorCodes.RESOURCE_LOCKED]: 'admin_and_system.resource_locked', [APIErrorCodes.SERVICE_UNAVAILABLE]: 'http.service_unavailable', diff --git a/packages/errors/src/i18n/ErrorI18nMessages.ts b/packages/errors/src/i18n/ErrorI18nMessages.ts index e108e7479..e17c48cd3 100644 --- a/packages/errors/src/i18n/ErrorI18nMessages.ts +++ b/packages/errors/src/i18n/ErrorI18nMessages.ts @@ -428,6 +428,7 @@ export const ERROR_I18N_MESSAGES = { 'moderation_and_reports.report_banned': "You've been banned from submitting reports.", 'moderation_and_reports.report_flow_outdated': 'The report form changed while you were filling it in. Start the report again.', + 'moderation_and_reports.report_under_legal_hold': "This report is under a legal hold and can't be deleted.", 'moderation_and_reports.unclaimed_account_cannot_submit_reports': 'You need to complete your account setup before you can submit reports.', 'moderation_and_reports.unknown_report': 'Unknown report.', diff --git a/packages/errors/src/i18n/locales/ar.ts b/packages/errors/src/i18n/locales/ar.ts index 657bc7adb..2e4e1882d 100644 --- a/packages/errors/src/i18n/locales/ar.ts +++ b/packages/errors/src/i18n/locales/ar.ts @@ -368,6 +368,7 @@ const ERROR_I18N_AR_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "تم حل البلاغ بالفعل.", "moderation_and_reports.report_banned": "تم حظرك من إرسال البلاغات.", "moderation_and_reports.report_flow_outdated": "تغيّر نموذج الإبلاغ أثناء تعبئتك له. ابدأ البلاغ من جديد.", + "moderation_and_reports.report_under_legal_hold": "هذا البلاغ خاضع لحجز قانوني ولا يمكن حذفه.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "تحتاج إلى إكمال إعداد حسابك قبل إرسال البلاغات.", "moderation_and_reports.unknown_report": "بلاغ غير معروف.", "moderation_and_reports.user_is_not_banned": "المستخدم غير محظور.", diff --git a/packages/errors/src/i18n/locales/bg.ts b/packages/errors/src/i18n/locales/bg.ts index 8a383ed0c..f539b2384 100644 --- a/packages/errors/src/i18n/locales/bg.ts +++ b/packages/errors/src/i18n/locales/bg.ts @@ -368,6 +368,7 @@ const ERROR_I18N_BG_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Сигналът вече е разгледан.", "moderation_and_reports.report_banned": "Имаш забрана да подаваш сигнали.", "moderation_and_reports.report_flow_outdated": "Формулярът за сигнал се промени, докато го попълваше. Започни сигнала отначало.", + "moderation_and_reports.report_under_legal_hold": "Този сигнал е под правно задържане и не може да бъде изтрит.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Трябва да завършиш настройката на акаунта си, преди да можеш да подаваш сигнали.", "moderation_and_reports.unknown_report": "Неизвестен сигнал.", "moderation_and_reports.user_is_not_banned": "Този потребител няма наложена забрана.", diff --git a/packages/errors/src/i18n/locales/cs.ts b/packages/errors/src/i18n/locales/cs.ts index 55d9fce9d..2f8f7090d 100644 --- a/packages/errors/src/i18n/locales/cs.ts +++ b/packages/errors/src/i18n/locales/cs.ts @@ -368,6 +368,7 @@ const ERROR_I18N_CS_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Nahlášení již bylo vyřešeno.", "moderation_and_reports.report_banned": "Máte zakázáno odesílat nahlášení.", "moderation_and_reports.report_flow_outdated": "Formulář nahlášení se během jeho vyplňování změnil. Nahlášení začněte znovu.", + "moderation_and_reports.report_under_legal_hold": "Toto nahlášení podléhá právnímu zadržení a nelze ho smazat.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Než budete moci odesílat nahlášení, musíte dokončit registraci účtu.", "moderation_and_reports.unknown_report": "Neznámé nahlášení.", "moderation_and_reports.user_is_not_banned": "Tento uživatel není zabanován.", diff --git a/packages/errors/src/i18n/locales/da.ts b/packages/errors/src/i18n/locales/da.ts index d13cfee6a..d1990ce6d 100644 --- a/packages/errors/src/i18n/locales/da.ts +++ b/packages/errors/src/i18n/locales/da.ts @@ -368,6 +368,7 @@ const ERROR_I18N_DA_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Anmeldelsen er allerede afsluttet.", "moderation_and_reports.report_banned": "Du er blevet udelukket fra at indsende anmeldelser.", "moderation_and_reports.report_flow_outdated": "Anmeldelsesformularen blev ændret, mens du udfyldte den. Start anmeldelsen forfra.", + "moderation_and_reports.report_under_legal_hold": "Denne anmeldelse er omfattet af et retligt tilbagehold og kan ikke slettes.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Du skal fuldføre oprettelsen af din konto, før du kan indsende anmeldelser.", "moderation_and_reports.unknown_report": "Ukendt anmeldelse.", "moderation_and_reports.user_is_not_banned": "Denne bruger er ikke udelukket.", diff --git a/packages/errors/src/i18n/locales/de.ts b/packages/errors/src/i18n/locales/de.ts index 3e76326ed..a84bf3ac2 100644 --- a/packages/errors/src/i18n/locales/de.ts +++ b/packages/errors/src/i18n/locales/de.ts @@ -368,6 +368,7 @@ const ERROR_I18N_DE_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Meldung bereits erledigt.", "moderation_and_reports.report_banned": "Du bist für das Einreichen von Meldungen gesperrt.", "moderation_and_reports.report_flow_outdated": "Das Meldeformular hat sich geändert, während du es ausgefüllt hast. Starte die Meldung erneut.", + "moderation_and_reports.report_under_legal_hold": "Diese Meldung unterliegt einer rechtlichen Aufbewahrungspflicht und kann nicht gelöscht werden.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Du musst die Einrichtung deines Accounts abschließen, bevor du Meldungen einreichen kannst.", "moderation_and_reports.unknown_report": "Unbekannte Meldung.", "moderation_and_reports.user_is_not_banned": "Dieser Benutzer ist nicht gebannt.", diff --git a/packages/errors/src/i18n/locales/el.ts b/packages/errors/src/i18n/locales/el.ts index 99cb70d05..bba0f4295 100644 --- a/packages/errors/src/i18n/locales/el.ts +++ b/packages/errors/src/i18n/locales/el.ts @@ -368,6 +368,7 @@ const ERROR_I18N_EL_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Η αναφορά έχει ήδη διευθετηθεί.", "moderation_and_reports.report_banned": "Έχεις αποκλειστεί από την υποβολή αναφορών.", "moderation_and_reports.report_flow_outdated": "Η φόρμα αναφοράς άλλαξε ενώ τη συμπλήρωνες. Ξεκίνα ξανά την αναφορά.", + "moderation_and_reports.report_under_legal_hold": "Αυτή η αναφορά τελεί υπό νομική δέσμευση και δεν μπορεί να διαγραφεί.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Πρέπει να ολοκληρώσεις τη ρύθμιση του λογαριασμού σου πριν υποβάλεις αναφορές.", "moderation_and_reports.unknown_report": "Άγνωστη αναφορά.", "moderation_and_reports.user_is_not_banned": "Αυτός ο χρήστης δεν έχει αποκλειστεί.", diff --git a/packages/errors/src/i18n/locales/en-GB.ts b/packages/errors/src/i18n/locales/en-GB.ts index 883fac3ba..b3160477a 100644 --- a/packages/errors/src/i18n/locales/en-GB.ts +++ b/packages/errors/src/i18n/locales/en-GB.ts @@ -368,6 +368,7 @@ const ERROR_I18N_EN_GB_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Report already resolved.", "moderation_and_reports.report_banned": "You've been banned from submitting reports.", "moderation_and_reports.report_flow_outdated": "The report form changed while you were filling it in. Start the report again.", + "moderation_and_reports.report_under_legal_hold": "This report is under a legal hold and can't be deleted.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "You need to complete your account setup before you can submit reports.", "moderation_and_reports.unknown_report": "Unknown report.", "moderation_and_reports.user_is_not_banned": "This user isn't banned.", diff --git a/packages/errors/src/i18n/locales/es-419.ts b/packages/errors/src/i18n/locales/es-419.ts index 08ef0e1d0..eca4d0bd8 100644 --- a/packages/errors/src/i18n/locales/es-419.ts +++ b/packages/errors/src/i18n/locales/es-419.ts @@ -368,6 +368,7 @@ const ERROR_I18N_ES_419_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "El reporte ya se resolvió.", "moderation_and_reports.report_banned": "Se te ha prohibido enviar reportes.", "moderation_and_reports.report_flow_outdated": "El formulario de reporte cambió mientras lo completabas. Vuelve a iniciar el reporte.", + "moderation_and_reports.report_under_legal_hold": "Este reporte está bajo retención legal y no se puede eliminar.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Tienes que completar el registro de tu cuenta antes de poder enviar reportes.", "moderation_and_reports.unknown_report": "Reporte desconocido.", "moderation_and_reports.user_is_not_banned": "Este usuario no ha sido baneado.", diff --git a/packages/errors/src/i18n/locales/es-ES.ts b/packages/errors/src/i18n/locales/es-ES.ts index afa8fd110..3912e4596 100644 --- a/packages/errors/src/i18n/locales/es-ES.ts +++ b/packages/errors/src/i18n/locales/es-ES.ts @@ -368,6 +368,7 @@ const ERROR_I18N_ES_ES_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "La denuncia ya se ha resuelto.", "moderation_and_reports.report_banned": "Se te ha prohibido enviar denuncias.", "moderation_and_reports.report_flow_outdated": "El formulario de denuncia ha cambiado mientras lo rellenabas. Vuelve a empezar la denuncia.", + "moderation_and_reports.report_under_legal_hold": "Esta denuncia está sujeta a una retención legal y no se puede eliminar.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Tienes que completar el registro de tu cuenta antes de poder enviar denuncias.", "moderation_and_reports.unknown_report": "Denuncia desconocida.", "moderation_and_reports.user_is_not_banned": "Este usuario no está baneado.", diff --git a/packages/errors/src/i18n/locales/fi.ts b/packages/errors/src/i18n/locales/fi.ts index 96e11051f..2499a39c4 100644 --- a/packages/errors/src/i18n/locales/fi.ts +++ b/packages/errors/src/i18n/locales/fi.ts @@ -368,6 +368,7 @@ const ERROR_I18N_FI_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Ilmoitus on jo ratkaistu.", "moderation_and_reports.report_banned": "Sinut on estetty lähettämästä ilmoituksia.", "moderation_and_reports.report_flow_outdated": "Ilmoituslomake muuttui, kun täytit sitä. Aloita ilmoitus uudelleen.", + "moderation_and_reports.report_under_legal_hold": "Tämä ilmoitus on oikeudellisen säilytysvelvoitteen alainen, eikä sitä voi poistaa.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Viimeistele tilisi rekisteröinti ennen kuin voit lähettää ilmoituksia.", "moderation_and_reports.unknown_report": "Tuntematon ilmoitus.", "moderation_and_reports.user_is_not_banned": "Tällä käyttäjällä ei ole porttikieltoa.", diff --git a/packages/errors/src/i18n/locales/fr.ts b/packages/errors/src/i18n/locales/fr.ts index b11f46e57..6979f837e 100644 --- a/packages/errors/src/i18n/locales/fr.ts +++ b/packages/errors/src/i18n/locales/fr.ts @@ -368,6 +368,7 @@ const ERROR_I18N_FR_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Signalement déjà résolu.", "moderation_and_reports.report_banned": "Vous n’êtes plus autorisé à envoyer des signalements.", "moderation_and_reports.report_flow_outdated": "Le formulaire de signalement a changé pendant que vous le remplissiez. Veuillez recommencer le signalement.", + "moderation_and_reports.report_under_legal_hold": "Ce signalement fait l’objet d’une conservation légale et ne peut pas être supprimé.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Vous devez finaliser votre inscription avant de pouvoir envoyer des signalements.", "moderation_and_reports.unknown_report": "Signalement inconnu.", "moderation_and_reports.user_is_not_banned": "Cet utilisateur n'est pas banni.", diff --git a/packages/errors/src/i18n/locales/he.ts b/packages/errors/src/i18n/locales/he.ts index 45943281c..5bebf5e1a 100644 --- a/packages/errors/src/i18n/locales/he.ts +++ b/packages/errors/src/i18n/locales/he.ts @@ -368,6 +368,7 @@ const ERROR_I18N_HE_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "הדיווח כבר טופל.", "moderation_and_reports.report_banned": "נחסמת מלהגיש דיווחים.", "moderation_and_reports.report_flow_outdated": "טופס הדיווח השתנה במהלך המילוי. יש להתחיל את הדיווח מחדש.", + "moderation_and_reports.report_under_legal_hold": "הדיווח הזה נתון להקפאה משפטית ואי אפשר למחוק אותו.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "עליך להשלים את הגדרת החשבון שלך לפני שאפשר להגיש דיווחים.", "moderation_and_reports.unknown_report": "דיווח לא ידוע.", "moderation_and_reports.user_is_not_banned": "משתמש זה לא חסום.", diff --git a/packages/errors/src/i18n/locales/hi.ts b/packages/errors/src/i18n/locales/hi.ts index ef2dd1559..cc8d5e21f 100644 --- a/packages/errors/src/i18n/locales/hi.ts +++ b/packages/errors/src/i18n/locales/hi.ts @@ -368,6 +368,7 @@ const ERROR_I18N_HI_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "रिपोर्ट पहले ही हल हो चुकी है।", "moderation_and_reports.report_banned": "आपको रिपोर्ट सबमिट करने से बैन कर दिया गया है।", "moderation_and_reports.report_flow_outdated": "रिपोर्ट फ़ॉर्म भरते समय बदल गया था। कृपया रिपोर्ट दोबारा शुरू करें।", + "moderation_and_reports.report_under_legal_hold": "यह रिपोर्ट कानूनी रोक के तहत है और इसे हटाया नहीं जा सकता।", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "रिपोर्ट सबमिट करने से पहले आपको अपना अकाउंट सेटअप पूरा करना होगा।", "moderation_and_reports.unknown_report": "अज्ञात रिपोर्ट।", "moderation_and_reports.user_is_not_banned": "यह यूज़र बैन नहीं है।", diff --git a/packages/errors/src/i18n/locales/hr.ts b/packages/errors/src/i18n/locales/hr.ts index 5051e0ae7..dd0874b48 100644 --- a/packages/errors/src/i18n/locales/hr.ts +++ b/packages/errors/src/i18n/locales/hr.ts @@ -368,6 +368,7 @@ const ERROR_I18N_HR_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Prijava je već riješena.", "moderation_and_reports.report_banned": "Zabranjeno ti je podnositi prijave.", "moderation_and_reports.report_flow_outdated": "Obrazac prijave promijenio se tijekom ispunjavanja. Započni prijavu ponovno.", + "moderation_and_reports.report_under_legal_hold": "Ova prijava podliježe pravnom zadržavanju i ne može se izbrisati.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Moraš dovršiti postavljanje računa prije nego što možeš podnositi prijave.", "moderation_and_reports.unknown_report": "Nepoznata prijava.", "moderation_and_reports.user_is_not_banned": "Ovaj korisnik nema zabranu.", diff --git a/packages/errors/src/i18n/locales/hu.ts b/packages/errors/src/i18n/locales/hu.ts index 47ead34b6..28c9a89da 100644 --- a/packages/errors/src/i18n/locales/hu.ts +++ b/packages/errors/src/i18n/locales/hu.ts @@ -368,6 +368,7 @@ const ERROR_I18N_HU_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "A bejelentést már lezárták.", "moderation_and_reports.report_banned": "Eltiltottak a bejelentések küldésétől.", "moderation_and_reports.report_flow_outdated": "A bejelentési űrlap megváltozott, amíg kitöltötted. Kezdd elölről a bejelentést.", + "moderation_and_reports.report_under_legal_hold": "Ez a bejelentés jogi visszatartás alatt áll, ezért nem törölhető.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Be kell fejezned a fiókod regisztrációját, mielőtt bejelentéseket küldhetnél.", "moderation_and_reports.unknown_report": "Ismeretlen bejelentés.", "moderation_and_reports.user_is_not_banned": "Ez a felhasználó nincs kitiltva.", diff --git a/packages/errors/src/i18n/locales/id.ts b/packages/errors/src/i18n/locales/id.ts index d3eb0285d..25957b313 100644 --- a/packages/errors/src/i18n/locales/id.ts +++ b/packages/errors/src/i18n/locales/id.ts @@ -368,6 +368,7 @@ const ERROR_I18N_ID_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Laporan sudah ditangani.", "moderation_and_reports.report_banned": "Kamu dilarang mengirim laporan.", "moderation_and_reports.report_flow_outdated": "Formulir laporan berubah saat kamu mengisinya. Mulai ulang laporanmu.", + "moderation_and_reports.report_under_legal_hold": "Laporan ini sedang ditahan karena alasan hukum dan tidak dapat dihapus.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Kamu perlu menyelesaikan penyiapan akunmu sebelum bisa mengirim laporan.", "moderation_and_reports.unknown_report": "Laporan tidak dikenal.", "moderation_and_reports.user_is_not_banned": "Pengguna ini tidak diblokir.", diff --git a/packages/errors/src/i18n/locales/it.ts b/packages/errors/src/i18n/locales/it.ts index b83f7a3a1..9953d14bc 100644 --- a/packages/errors/src/i18n/locales/it.ts +++ b/packages/errors/src/i18n/locales/it.ts @@ -368,6 +368,7 @@ const ERROR_I18N_IT_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "La segnalazione è già stata risolta.", "moderation_and_reports.report_banned": "Non hai più il permesso di inviare segnalazioni.", "moderation_and_reports.report_flow_outdated": "Il modulo di segnalazione è cambiato mentre lo stavi compilando. Ricomincia la segnalazione.", + "moderation_and_reports.report_under_legal_hold": "Questa segnalazione è soggetta a un vincolo di conservazione legale e non può essere eliminata.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Devi completare la registrazione del tuo account prima di poter inviare segnalazioni.", "moderation_and_reports.unknown_report": "Segnalazione sconosciuta.", "moderation_and_reports.user_is_not_banned": "L'utente non è bannato.", diff --git a/packages/errors/src/i18n/locales/ja.ts b/packages/errors/src/i18n/locales/ja.ts index e5dd3025e..71b167272 100644 --- a/packages/errors/src/i18n/locales/ja.ts +++ b/packages/errors/src/i18n/locales/ja.ts @@ -368,6 +368,7 @@ const ERROR_I18N_JA_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "報告はすでに解決済みです。", "moderation_and_reports.report_banned": "報告の送信は禁止されています。", "moderation_and_reports.report_flow_outdated": "入力中に報告フォームが変更されました。最初から報告し直してください。", + "moderation_and_reports.report_under_legal_hold": "この報告は法的保全の対象のため削除できません。", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "報告を送信する前に、アカウント設定を完了する必要があります。", "moderation_and_reports.unknown_report": "不明な報告です。", "moderation_and_reports.user_is_not_banned": "このユーザーはBANされていません。", diff --git a/packages/errors/src/i18n/locales/ko.ts b/packages/errors/src/i18n/locales/ko.ts index b683c6b44..00e55f936 100644 --- a/packages/errors/src/i18n/locales/ko.ts +++ b/packages/errors/src/i18n/locales/ko.ts @@ -368,6 +368,7 @@ const ERROR_I18N_KO_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "이미 해결된 신고예요.", "moderation_and_reports.report_banned": "신고 제출이 금지되었어요.", "moderation_and_reports.report_flow_outdated": "작성하는 동안 신고 양식이 변경되었어요. 신고를 다시 시작해 주세요.", + "moderation_and_reports.report_under_legal_hold": "이 신고는 법적 보존 조치 대상이라 삭제할 수 없어요.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "신고를 제출하려면 계정 설정을 완료해야 해요.", "moderation_and_reports.unknown_report": "알 수 없는 신고예요.", "moderation_and_reports.user_is_not_banned": "이 사용자는 차단되지 않았어요.", diff --git a/packages/errors/src/i18n/locales/lt.ts b/packages/errors/src/i18n/locales/lt.ts index 605ef8bfa..4c0291f68 100644 --- a/packages/errors/src/i18n/locales/lt.ts +++ b/packages/errors/src/i18n/locales/lt.ts @@ -368,6 +368,7 @@ const ERROR_I18N_LT_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Pranešimas jau išnagrinėtas.", "moderation_and_reports.report_banned": "Tau uždrausta teikti pranešimus.", "moderation_and_reports.report_flow_outdated": "Pranešimo forma pasikeitė, kol ją pildei. Pradėk pranešimą iš naujo.", + "moderation_and_reports.report_under_legal_hold": "Šiam pranešimui taikomas teisinis išsaugojimas, todėl jo ištrinti negalima.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Kad galėtum teikti pranešimus, turi užbaigti paskyros nustatymą.", "moderation_and_reports.unknown_report": "Nežinomas pranešimas.", "moderation_and_reports.user_is_not_banned": "Šis naudotojas nėra užblokuotas.", diff --git a/packages/errors/src/i18n/locales/nl.ts b/packages/errors/src/i18n/locales/nl.ts index 470e556a6..26301cb5b 100644 --- a/packages/errors/src/i18n/locales/nl.ts +++ b/packages/errors/src/i18n/locales/nl.ts @@ -368,6 +368,7 @@ const ERROR_I18N_NL_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "De melding is al afgehandeld.", "moderation_and_reports.report_banned": "Je bent uitgesloten van het indienen van meldingen.", "moderation_and_reports.report_flow_outdated": "Het meldingsformulier is gewijzigd terwijl je het invulde. Begin opnieuw met je melding.", + "moderation_and_reports.report_under_legal_hold": "Deze melding valt onder een juridische bewaarplicht en kan niet worden verwijderd.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Je moet de registratie van je account voltooien voordat je meldingen kunt indienen.", "moderation_and_reports.unknown_report": "Onbekende melding.", "moderation_and_reports.user_is_not_banned": "Deze gebruiker is niet verbannen.", diff --git a/packages/errors/src/i18n/locales/no.ts b/packages/errors/src/i18n/locales/no.ts index fd60773c7..bb7a6b520 100644 --- a/packages/errors/src/i18n/locales/no.ts +++ b/packages/errors/src/i18n/locales/no.ts @@ -368,6 +368,7 @@ const ERROR_I18N_NO_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Rapporten er allerede behandlet.", "moderation_and_reports.report_banned": "Du er utestengt fra å sende rapporter.", "moderation_and_reports.report_flow_outdated": "Rapportskjemaet ble endret mens du fylte det ut. Start rapporten på nytt.", + "moderation_and_reports.report_under_legal_hold": "Denne rapporten er underlagt rettslig bevaring og kan ikke slettes.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Du må fullføre registreringen av kontoen din før du kan sende rapporter.", "moderation_and_reports.unknown_report": "Ukjent rapport.", "moderation_and_reports.user_is_not_banned": "Denne brukeren er ikke utestengt.", diff --git a/packages/errors/src/i18n/locales/pl.ts b/packages/errors/src/i18n/locales/pl.ts index b0d6f935f..d292a801e 100644 --- a/packages/errors/src/i18n/locales/pl.ts +++ b/packages/errors/src/i18n/locales/pl.ts @@ -368,6 +368,7 @@ const ERROR_I18N_PL_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Zgłoszenie zostało już rozpatrzone.", "moderation_and_reports.report_banned": "Zablokowano Ci możliwość przesyłania zgłoszeń.", "moderation_and_reports.report_flow_outdated": "Formularz zgłoszenia zmienił się podczas jego wypełniania. Rozpocznij zgłoszenie ponownie.", + "moderation_and_reports.report_under_legal_hold": "To zgłoszenie podlega prawnemu zabezpieczeniu i nie można go usunąć.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Dokończ rejestrację konta, zanim wyślesz zgłoszenia.", "moderation_and_reports.unknown_report": "Nieznane zgłoszenie.", "moderation_and_reports.user_is_not_banned": "Ten użytkownik nie ma bana.", diff --git a/packages/errors/src/i18n/locales/pt-BR.ts b/packages/errors/src/i18n/locales/pt-BR.ts index 559ff009d..375745cc5 100644 --- a/packages/errors/src/i18n/locales/pt-BR.ts +++ b/packages/errors/src/i18n/locales/pt-BR.ts @@ -368,6 +368,7 @@ const ERROR_I18N_PT_BR_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "A denúncia já foi resolvida.", "moderation_and_reports.report_banned": "Você foi impedido de enviar denúncias.", "moderation_and_reports.report_flow_outdated": "O formulário de denúncia mudou enquanto você o preenchia. Comece a denúncia novamente.", + "moderation_and_reports.report_under_legal_hold": "Esta denúncia está sob retenção legal e não pode ser excluída.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Você precisa terminar a configuração da sua conta antes de fazer denúncias.", "moderation_and_reports.unknown_report": "Denúncia não encontrada.", "moderation_and_reports.user_is_not_banned": "Este usuário não está banido.", diff --git a/packages/errors/src/i18n/locales/ro.ts b/packages/errors/src/i18n/locales/ro.ts index cff310706..090e549d5 100644 --- a/packages/errors/src/i18n/locales/ro.ts +++ b/packages/errors/src/i18n/locales/ro.ts @@ -368,6 +368,7 @@ const ERROR_I18N_RO_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Raportul a fost deja rezolvat.", "moderation_and_reports.report_banned": "Ți s-a interzis să trimiți rapoarte.", "moderation_and_reports.report_flow_outdated": "Formularul de raportare s-a modificat în timp ce îl completai. Repornește raportarea.", + "moderation_and_reports.report_under_legal_hold": "Acest raport este supus unei rețineri legale și nu poate fi șters.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Trebuie să-ți finalizezi configurarea contului înainte să poți trimite rapoarte.", "moderation_and_reports.unknown_report": "Raport necunoscut.", "moderation_and_reports.user_is_not_banned": "Acest utilizator nu este banat.", diff --git a/packages/errors/src/i18n/locales/ru.ts b/packages/errors/src/i18n/locales/ru.ts index 786832897..c04702660 100644 --- a/packages/errors/src/i18n/locales/ru.ts +++ b/packages/errors/src/i18n/locales/ru.ts @@ -368,6 +368,7 @@ const ERROR_I18N_RU_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Жалоба уже рассмотрена.", "moderation_and_reports.report_banned": "Ты забанен и не можешь отправлять жалобы.", "moderation_and_reports.report_flow_outdated": "Форма жалобы изменилась во время заполнения. Начни жалобу заново.", + "moderation_and_reports.report_under_legal_hold": "Эта жалоба находится на юридическом удержании и не может быть удалена.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Тебе нужно завершить настройку аккаунта, чтобы подавать жалобы.", "moderation_and_reports.unknown_report": "Жалоба не найдена.", "moderation_and_reports.user_is_not_banned": "Этот пользователь не забанен.", diff --git a/packages/errors/src/i18n/locales/sv-SE.ts b/packages/errors/src/i18n/locales/sv-SE.ts index 7ca198a2b..e3d9aaf35 100644 --- a/packages/errors/src/i18n/locales/sv-SE.ts +++ b/packages/errors/src/i18n/locales/sv-SE.ts @@ -368,6 +368,7 @@ const ERROR_I18N_SV_SE_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Anmälan har redan behandlats.", "moderation_and_reports.report_banned": "Du har förbjudits att skicka in anmälningar.", "moderation_and_reports.report_flow_outdated": "Anmälningsformuläret ändrades medan du fyllde i det. Börja om med anmälan.", + "moderation_and_reports.report_under_legal_hold": "Den här anmälan omfattas av ett rättsligt bevarandekrav och kan inte raderas.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Du måste slutföra konfigureringen av ditt konto innan du kan skicka in anmälningar.", "moderation_and_reports.unknown_report": "Okänd anmälan.", "moderation_and_reports.user_is_not_banned": "Den här användaren är inte bannlyst.", diff --git a/packages/errors/src/i18n/locales/th.ts b/packages/errors/src/i18n/locales/th.ts index 1ebf83b8e..59d485750 100644 --- a/packages/errors/src/i18n/locales/th.ts +++ b/packages/errors/src/i18n/locales/th.ts @@ -368,6 +368,7 @@ const ERROR_I18N_TH_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "รายงานนี้ได้รับการดำเนินการแล้ว", "moderation_and_reports.report_banned": "คุณถูกแบนจากการส่งรายงาน", "moderation_and_reports.report_flow_outdated": "แบบฟอร์มรายงานมีการเปลี่ยนแปลงขณะที่คุณกำลังกรอกข้อมูลอยู่ โปรดเริ่มรายงานใหม่อีกครั้ง", + "moderation_and_reports.report_under_legal_hold": "รายงานนี้อยู่ระหว่างการระงับตามกฎหมายและไม่สามารถลบได้", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "คุณต้องตั้งค่าบัญชีให้เสร็จสมบูรณ์ก่อนจึงจะสามารถส่งรายงานได้", "moderation_and_reports.unknown_report": "ไม่พบรายงาน", "moderation_and_reports.user_is_not_banned": "ผู้ใช้รายนี้ไม่ได้ถูกแบน", diff --git a/packages/errors/src/i18n/locales/tr.ts b/packages/errors/src/i18n/locales/tr.ts index 41b50d4f4..38a60074b 100644 --- a/packages/errors/src/i18n/locales/tr.ts +++ b/packages/errors/src/i18n/locales/tr.ts @@ -368,6 +368,7 @@ const ERROR_I18N_TR_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Rapor zaten çözüldü.", "moderation_and_reports.report_banned": "Rapor göndermen yasaklandı.", "moderation_and_reports.report_flow_outdated": "Rapor formu sen doldururken değişti. Raporu yeniden başlat.", + "moderation_and_reports.report_under_legal_hold": "Bu rapor yasal saklama kapsamında olduğu için silinemez.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Rapor göndermeden önce hesap kurulumunu tamamlamalısın.", "moderation_and_reports.unknown_report": "Bilinmeyen rapor.", "moderation_and_reports.user_is_not_banned": "Bu kullanıcı yasaklı değil.", diff --git a/packages/errors/src/i18n/locales/uk.ts b/packages/errors/src/i18n/locales/uk.ts index e48aa56c1..b26bb1cd5 100644 --- a/packages/errors/src/i18n/locales/uk.ts +++ b/packages/errors/src/i18n/locales/uk.ts @@ -368,6 +368,7 @@ const ERROR_I18N_UK_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Звіт вже розглянуто.", "moderation_and_reports.report_banned": "Тобі заборонено надсилати звіти.", "moderation_and_reports.report_flow_outdated": "Форма звіту змінилася під час заповнення. Почни звіт заново.", + "moderation_and_reports.report_under_legal_hold": "Цей звіт перебуває під юридичним утриманням, і його не можна видалити.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Тобі потрібно завершити налаштування акаунта, щоб надсилати звіти.", "moderation_and_reports.unknown_report": "Невідомий звіт.", "moderation_and_reports.user_is_not_banned": "Цей користувач не забанений.", diff --git a/packages/errors/src/i18n/locales/vi.ts b/packages/errors/src/i18n/locales/vi.ts index 3984a81e8..3853becf7 100644 --- a/packages/errors/src/i18n/locales/vi.ts +++ b/packages/errors/src/i18n/locales/vi.ts @@ -368,6 +368,7 @@ const ERROR_I18N_VI_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "Báo cáo đã được giải quyết.", "moderation_and_reports.report_banned": "Bạn đã bị cấm gửi báo cáo.", "moderation_and_reports.report_flow_outdated": "Biểu mẫu báo cáo đã thay đổi trong khi bạn đang điền. Hãy bắt đầu lại báo cáo.", + "moderation_and_reports.report_under_legal_hold": "Báo cáo này đang bị lưu giữ theo yêu cầu pháp lý và không thể xóa.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Bạn cần hoàn tất thiết lập tài khoản trước khi có thể gửi báo cáo.", "moderation_and_reports.unknown_report": "Báo cáo không xác định.", "moderation_and_reports.user_is_not_banned": "Người dùng này không bị cấm.", diff --git a/packages/errors/src/i18n/locales/zh-CN.ts b/packages/errors/src/i18n/locales/zh-CN.ts index 3d81229dd..02a1b8e52 100644 --- a/packages/errors/src/i18n/locales/zh-CN.ts +++ b/packages/errors/src/i18n/locales/zh-CN.ts @@ -368,6 +368,7 @@ const ERROR_I18N_ZH_CN_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "举报已解决。", "moderation_and_reports.report_banned": "你已被禁止提交举报。", "moderation_and_reports.report_flow_outdated": "举报表单在你填写过程中已更新。请重新开始举报。", + "moderation_and_reports.report_under_legal_hold": "该举报处于法律保全状态,无法删除。", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "你需要完成账号设置才能提交举报。", "moderation_and_reports.unknown_report": "未知举报。", "moderation_and_reports.user_is_not_banned": "此用户未被封禁。", diff --git a/packages/errors/src/i18n/locales/zh-TW.ts b/packages/errors/src/i18n/locales/zh-TW.ts index e6f5ea70b..3e76e7251 100644 --- a/packages/errors/src/i18n/locales/zh-TW.ts +++ b/packages/errors/src/i18n/locales/zh-TW.ts @@ -368,6 +368,7 @@ const ERROR_I18N_ZH_TW_MESSAGES = defineErrorI18nLocaleMessages({ "moderation_and_reports.report_already_resolved": "檢舉已解決。", "moderation_and_reports.report_banned": "你已被禁止送出檢舉。", "moderation_and_reports.report_flow_outdated": "檢舉表單在你填寫時已變更。請重新開始檢舉。", + "moderation_and_reports.report_under_legal_hold": "此檢舉受法律保全約束,無法刪除。", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "你必須完成帳號設定才能送出檢舉。", "moderation_and_reports.unknown_report": "未知的檢舉。", "moderation_and_reports.user_is_not_banned": "此使用者未被封鎖。", diff --git a/packages/errors/src/i18n/weblate/locales/ar.json b/packages/errors/src/i18n/weblate/locales/ar.json index 47a187b22..b6e9c54a9 100644 --- a/packages/errors/src/i18n/weblate/locales/ar.json +++ b/packages/errors/src/i18n/weblate/locales/ar.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "تم حل البلاغ بالفعل.", "moderation_and_reports.report_banned": "تم حظرك من إرسال البلاغات.", "moderation_and_reports.report_flow_outdated": "تغيّر نموذج الإبلاغ أثناء تعبئتك له. ابدأ البلاغ من جديد.", + "moderation_and_reports.report_under_legal_hold": "هذا البلاغ خاضع لحجز قانوني ولا يمكن حذفه.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "تحتاج إلى إكمال إعداد حسابك قبل إرسال البلاغات.", "moderation_and_reports.unknown_report": "بلاغ غير معروف.", "moderation_and_reports.user_is_not_banned": "المستخدم غير محظور.", diff --git a/packages/errors/src/i18n/weblate/locales/bg.json b/packages/errors/src/i18n/weblate/locales/bg.json index 67712a800..942e4f68a 100644 --- a/packages/errors/src/i18n/weblate/locales/bg.json +++ b/packages/errors/src/i18n/weblate/locales/bg.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Сигналът вече е разгледан.", "moderation_and_reports.report_banned": "Имаш забрана да подаваш сигнали.", "moderation_and_reports.report_flow_outdated": "Формулярът за сигнал се промени, докато го попълваше. Започни сигнала отначало.", + "moderation_and_reports.report_under_legal_hold": "Този сигнал е под правно задържане и не може да бъде изтрит.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Трябва да завършиш настройката на акаунта си, преди да можеш да подаваш сигнали.", "moderation_and_reports.unknown_report": "Неизвестен сигнал.", "moderation_and_reports.user_is_not_banned": "Този потребител няма наложена забрана.", diff --git a/packages/errors/src/i18n/weblate/locales/cs.json b/packages/errors/src/i18n/weblate/locales/cs.json index b08da9563..8836e0dbf 100644 --- a/packages/errors/src/i18n/weblate/locales/cs.json +++ b/packages/errors/src/i18n/weblate/locales/cs.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Nahlášení již bylo vyřešeno.", "moderation_and_reports.report_banned": "Máte zakázáno odesílat nahlášení.", "moderation_and_reports.report_flow_outdated": "Formulář nahlášení se během jeho vyplňování změnil. Nahlášení začněte znovu.", + "moderation_and_reports.report_under_legal_hold": "Toto nahlášení podléhá právnímu zadržení a nelze ho smazat.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Než budete moci odesílat nahlášení, musíte dokončit registraci účtu.", "moderation_and_reports.unknown_report": "Neznámé nahlášení.", "moderation_and_reports.user_is_not_banned": "Tento uživatel není zabanován.", diff --git a/packages/errors/src/i18n/weblate/locales/da.json b/packages/errors/src/i18n/weblate/locales/da.json index 26bb882cd..3e9cc52ce 100644 --- a/packages/errors/src/i18n/weblate/locales/da.json +++ b/packages/errors/src/i18n/weblate/locales/da.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Anmeldelsen er allerede afsluttet.", "moderation_and_reports.report_banned": "Du er blevet udelukket fra at indsende anmeldelser.", "moderation_and_reports.report_flow_outdated": "Anmeldelsesformularen blev ændret, mens du udfyldte den. Start anmeldelsen forfra.", + "moderation_and_reports.report_under_legal_hold": "Denne anmeldelse er omfattet af et retligt tilbagehold og kan ikke slettes.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Du skal fuldføre oprettelsen af din konto, før du kan indsende anmeldelser.", "moderation_and_reports.unknown_report": "Ukendt anmeldelse.", "moderation_and_reports.user_is_not_banned": "Denne bruger er ikke udelukket.", diff --git a/packages/errors/src/i18n/weblate/locales/de.json b/packages/errors/src/i18n/weblate/locales/de.json index 1b85e85a5..eeed78808 100644 --- a/packages/errors/src/i18n/weblate/locales/de.json +++ b/packages/errors/src/i18n/weblate/locales/de.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Meldung bereits erledigt.", "moderation_and_reports.report_banned": "Du bist für das Einreichen von Meldungen gesperrt.", "moderation_and_reports.report_flow_outdated": "Das Meldeformular hat sich geändert, während du es ausgefüllt hast. Starte die Meldung erneut.", + "moderation_and_reports.report_under_legal_hold": "Diese Meldung unterliegt einer rechtlichen Aufbewahrungspflicht und kann nicht gelöscht werden.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Du musst die Einrichtung deines Accounts abschließen, bevor du Meldungen einreichen kannst.", "moderation_and_reports.unknown_report": "Unbekannte Meldung.", "moderation_and_reports.user_is_not_banned": "Dieser Benutzer ist nicht gebannt.", diff --git a/packages/errors/src/i18n/weblate/locales/el.json b/packages/errors/src/i18n/weblate/locales/el.json index 8f605422c..e4255a958 100644 --- a/packages/errors/src/i18n/weblate/locales/el.json +++ b/packages/errors/src/i18n/weblate/locales/el.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Η αναφορά έχει ήδη διευθετηθεί.", "moderation_and_reports.report_banned": "Έχεις αποκλειστεί από την υποβολή αναφορών.", "moderation_and_reports.report_flow_outdated": "Η φόρμα αναφοράς άλλαξε ενώ τη συμπλήρωνες. Ξεκίνα ξανά την αναφορά.", + "moderation_and_reports.report_under_legal_hold": "Αυτή η αναφορά τελεί υπό νομική δέσμευση και δεν μπορεί να διαγραφεί.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Πρέπει να ολοκληρώσεις τη ρύθμιση του λογαριασμού σου πριν υποβάλεις αναφορές.", "moderation_and_reports.unknown_report": "Άγνωστη αναφορά.", "moderation_and_reports.user_is_not_banned": "Αυτός ο χρήστης δεν έχει αποκλειστεί.", diff --git a/packages/errors/src/i18n/weblate/locales/en-GB.json b/packages/errors/src/i18n/weblate/locales/en-GB.json index 0e1d09f37..9772a158b 100644 --- a/packages/errors/src/i18n/weblate/locales/en-GB.json +++ b/packages/errors/src/i18n/weblate/locales/en-GB.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Report already resolved.", "moderation_and_reports.report_banned": "You've been banned from submitting reports.", "moderation_and_reports.report_flow_outdated": "The report form changed while you were filling it in. Start the report again.", + "moderation_and_reports.report_under_legal_hold": "This report is under a legal hold and can't be deleted.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "You need to complete your account setup before you can submit reports.", "moderation_and_reports.unknown_report": "Unknown report.", "moderation_and_reports.user_is_not_banned": "This user isn't banned.", diff --git a/packages/errors/src/i18n/weblate/locales/es-419.json b/packages/errors/src/i18n/weblate/locales/es-419.json index 60117c599..e3847f548 100644 --- a/packages/errors/src/i18n/weblate/locales/es-419.json +++ b/packages/errors/src/i18n/weblate/locales/es-419.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "El reporte ya se resolvió.", "moderation_and_reports.report_banned": "Se te ha prohibido enviar reportes.", "moderation_and_reports.report_flow_outdated": "El formulario de reporte cambió mientras lo completabas. Vuelve a iniciar el reporte.", + "moderation_and_reports.report_under_legal_hold": "Este reporte está bajo retención legal y no se puede eliminar.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Tienes que completar el registro de tu cuenta antes de poder enviar reportes.", "moderation_and_reports.unknown_report": "Reporte desconocido.", "moderation_and_reports.user_is_not_banned": "Este usuario no ha sido baneado.", diff --git a/packages/errors/src/i18n/weblate/locales/es-ES.json b/packages/errors/src/i18n/weblate/locales/es-ES.json index bdbe67873..e8bc1c0a5 100644 --- a/packages/errors/src/i18n/weblate/locales/es-ES.json +++ b/packages/errors/src/i18n/weblate/locales/es-ES.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "La denuncia ya se ha resuelto.", "moderation_and_reports.report_banned": "Se te ha prohibido enviar denuncias.", "moderation_and_reports.report_flow_outdated": "El formulario de denuncia ha cambiado mientras lo rellenabas. Vuelve a empezar la denuncia.", + "moderation_and_reports.report_under_legal_hold": "Esta denuncia está sujeta a una retención legal y no se puede eliminar.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Tienes que completar el registro de tu cuenta antes de poder enviar denuncias.", "moderation_and_reports.unknown_report": "Denuncia desconocida.", "moderation_and_reports.user_is_not_banned": "Este usuario no está baneado.", diff --git a/packages/errors/src/i18n/weblate/locales/fi.json b/packages/errors/src/i18n/weblate/locales/fi.json index 5ec4b8cec..644efd157 100644 --- a/packages/errors/src/i18n/weblate/locales/fi.json +++ b/packages/errors/src/i18n/weblate/locales/fi.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Ilmoitus on jo ratkaistu.", "moderation_and_reports.report_banned": "Sinut on estetty lähettämästä ilmoituksia.", "moderation_and_reports.report_flow_outdated": "Ilmoituslomake muuttui, kun täytit sitä. Aloita ilmoitus uudelleen.", + "moderation_and_reports.report_under_legal_hold": "Tämä ilmoitus on oikeudellisen säilytysvelvoitteen alainen, eikä sitä voi poistaa.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Viimeistele tilisi rekisteröinti ennen kuin voit lähettää ilmoituksia.", "moderation_and_reports.unknown_report": "Tuntematon ilmoitus.", "moderation_and_reports.user_is_not_banned": "Tällä käyttäjällä ei ole porttikieltoa.", diff --git a/packages/errors/src/i18n/weblate/locales/fr.json b/packages/errors/src/i18n/weblate/locales/fr.json index 8713c666c..05bd2db7b 100644 --- a/packages/errors/src/i18n/weblate/locales/fr.json +++ b/packages/errors/src/i18n/weblate/locales/fr.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Signalement déjà résolu.", "moderation_and_reports.report_banned": "Vous n’êtes plus autorisé à envoyer des signalements.", "moderation_and_reports.report_flow_outdated": "Le formulaire de signalement a changé pendant que vous le remplissiez. Veuillez recommencer le signalement.", + "moderation_and_reports.report_under_legal_hold": "Ce signalement fait l’objet d’une conservation légale et ne peut pas être supprimé.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Vous devez finaliser votre inscription avant de pouvoir envoyer des signalements.", "moderation_and_reports.unknown_report": "Signalement inconnu.", "moderation_and_reports.user_is_not_banned": "Cet utilisateur n'est pas banni.", diff --git a/packages/errors/src/i18n/weblate/locales/he.json b/packages/errors/src/i18n/weblate/locales/he.json index 672a61b81..4ddde27de 100644 --- a/packages/errors/src/i18n/weblate/locales/he.json +++ b/packages/errors/src/i18n/weblate/locales/he.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "הדיווח כבר טופל.", "moderation_and_reports.report_banned": "נחסמת מלהגיש דיווחים.", "moderation_and_reports.report_flow_outdated": "טופס הדיווח השתנה במהלך המילוי. יש להתחיל את הדיווח מחדש.", + "moderation_and_reports.report_under_legal_hold": "הדיווח הזה נתון להקפאה משפטית ואי אפשר למחוק אותו.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "עליך להשלים את הגדרת החשבון שלך לפני שאפשר להגיש דיווחים.", "moderation_and_reports.unknown_report": "דיווח לא ידוע.", "moderation_and_reports.user_is_not_banned": "משתמש זה לא חסום.", diff --git a/packages/errors/src/i18n/weblate/locales/hi.json b/packages/errors/src/i18n/weblate/locales/hi.json index 9dcaeddfb..643a02b4e 100644 --- a/packages/errors/src/i18n/weblate/locales/hi.json +++ b/packages/errors/src/i18n/weblate/locales/hi.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "रिपोर्ट पहले ही हल हो चुकी है।", "moderation_and_reports.report_banned": "आपको रिपोर्ट सबमिट करने से बैन कर दिया गया है।", "moderation_and_reports.report_flow_outdated": "रिपोर्ट फ़ॉर्म भरते समय बदल गया था। कृपया रिपोर्ट दोबारा शुरू करें।", + "moderation_and_reports.report_under_legal_hold": "यह रिपोर्ट कानूनी रोक के तहत है और इसे हटाया नहीं जा सकता।", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "रिपोर्ट सबमिट करने से पहले आपको अपना अकाउंट सेटअप पूरा करना होगा।", "moderation_and_reports.unknown_report": "अज्ञात रिपोर्ट।", "moderation_and_reports.user_is_not_banned": "यह यूज़र बैन नहीं है।", diff --git a/packages/errors/src/i18n/weblate/locales/hr.json b/packages/errors/src/i18n/weblate/locales/hr.json index fc721d83c..09d11791d 100644 --- a/packages/errors/src/i18n/weblate/locales/hr.json +++ b/packages/errors/src/i18n/weblate/locales/hr.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Prijava je već riješena.", "moderation_and_reports.report_banned": "Zabranjeno ti je podnositi prijave.", "moderation_and_reports.report_flow_outdated": "Obrazac prijave promijenio se tijekom ispunjavanja. Započni prijavu ponovno.", + "moderation_and_reports.report_under_legal_hold": "Ova prijava podliježe pravnom zadržavanju i ne može se izbrisati.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Moraš dovršiti postavljanje računa prije nego što možeš podnositi prijave.", "moderation_and_reports.unknown_report": "Nepoznata prijava.", "moderation_and_reports.user_is_not_banned": "Ovaj korisnik nema zabranu.", diff --git a/packages/errors/src/i18n/weblate/locales/hu.json b/packages/errors/src/i18n/weblate/locales/hu.json index 1601b60d1..3bfa75d19 100644 --- a/packages/errors/src/i18n/weblate/locales/hu.json +++ b/packages/errors/src/i18n/weblate/locales/hu.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "A bejelentést már lezárták.", "moderation_and_reports.report_banned": "Eltiltottak a bejelentések küldésétől.", "moderation_and_reports.report_flow_outdated": "A bejelentési űrlap megváltozott, amíg kitöltötted. Kezdd elölről a bejelentést.", + "moderation_and_reports.report_under_legal_hold": "Ez a bejelentés jogi visszatartás alatt áll, ezért nem törölhető.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Be kell fejezned a fiókod regisztrációját, mielőtt bejelentéseket küldhetnél.", "moderation_and_reports.unknown_report": "Ismeretlen bejelentés.", "moderation_and_reports.user_is_not_banned": "Ez a felhasználó nincs kitiltva.", diff --git a/packages/errors/src/i18n/weblate/locales/id.json b/packages/errors/src/i18n/weblate/locales/id.json index 447ddab0d..8e4121c24 100644 --- a/packages/errors/src/i18n/weblate/locales/id.json +++ b/packages/errors/src/i18n/weblate/locales/id.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Laporan sudah ditangani.", "moderation_and_reports.report_banned": "Kamu dilarang mengirim laporan.", "moderation_and_reports.report_flow_outdated": "Formulir laporan berubah saat kamu mengisinya. Mulai ulang laporanmu.", + "moderation_and_reports.report_under_legal_hold": "Laporan ini sedang ditahan karena alasan hukum dan tidak dapat dihapus.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Kamu perlu menyelesaikan penyiapan akunmu sebelum bisa mengirim laporan.", "moderation_and_reports.unknown_report": "Laporan tidak dikenal.", "moderation_and_reports.user_is_not_banned": "Pengguna ini tidak diblokir.", diff --git a/packages/errors/src/i18n/weblate/locales/it.json b/packages/errors/src/i18n/weblate/locales/it.json index 1eec9fb3a..96e38e268 100644 --- a/packages/errors/src/i18n/weblate/locales/it.json +++ b/packages/errors/src/i18n/weblate/locales/it.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "La segnalazione è già stata risolta.", "moderation_and_reports.report_banned": "Non hai più il permesso di inviare segnalazioni.", "moderation_and_reports.report_flow_outdated": "Il modulo di segnalazione è cambiato mentre lo stavi compilando. Ricomincia la segnalazione.", + "moderation_and_reports.report_under_legal_hold": "Questa segnalazione è soggetta a un vincolo di conservazione legale e non può essere eliminata.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Devi completare la registrazione del tuo account prima di poter inviare segnalazioni.", "moderation_and_reports.unknown_report": "Segnalazione sconosciuta.", "moderation_and_reports.user_is_not_banned": "L'utente non è bannato.", diff --git a/packages/errors/src/i18n/weblate/locales/ja.json b/packages/errors/src/i18n/weblate/locales/ja.json index 72fa475d8..909c82b11 100644 --- a/packages/errors/src/i18n/weblate/locales/ja.json +++ b/packages/errors/src/i18n/weblate/locales/ja.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "報告はすでに解決済みです。", "moderation_and_reports.report_banned": "報告の送信は禁止されています。", "moderation_and_reports.report_flow_outdated": "入力中に報告フォームが変更されました。最初から報告し直してください。", + "moderation_and_reports.report_under_legal_hold": "この報告は法的保全の対象のため削除できません。", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "報告を送信する前に、アカウント設定を完了する必要があります。", "moderation_and_reports.unknown_report": "不明な報告です。", "moderation_and_reports.user_is_not_banned": "このユーザーはBANされていません。", diff --git a/packages/errors/src/i18n/weblate/locales/ko.json b/packages/errors/src/i18n/weblate/locales/ko.json index 97faebbe1..66c907f2b 100644 --- a/packages/errors/src/i18n/weblate/locales/ko.json +++ b/packages/errors/src/i18n/weblate/locales/ko.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "이미 해결된 신고예요.", "moderation_and_reports.report_banned": "신고 제출이 금지되었어요.", "moderation_and_reports.report_flow_outdated": "작성하는 동안 신고 양식이 변경되었어요. 신고를 다시 시작해 주세요.", + "moderation_and_reports.report_under_legal_hold": "이 신고는 법적 보존 조치 대상이라 삭제할 수 없어요.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "신고를 제출하려면 계정 설정을 완료해야 해요.", "moderation_and_reports.unknown_report": "알 수 없는 신고예요.", "moderation_and_reports.user_is_not_banned": "이 사용자는 차단되지 않았어요.", diff --git a/packages/errors/src/i18n/weblate/locales/lt.json b/packages/errors/src/i18n/weblate/locales/lt.json index 385444ee3..c29deaef0 100644 --- a/packages/errors/src/i18n/weblate/locales/lt.json +++ b/packages/errors/src/i18n/weblate/locales/lt.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Pranešimas jau išnagrinėtas.", "moderation_and_reports.report_banned": "Tau uždrausta teikti pranešimus.", "moderation_and_reports.report_flow_outdated": "Pranešimo forma pasikeitė, kol ją pildei. Pradėk pranešimą iš naujo.", + "moderation_and_reports.report_under_legal_hold": "Šiam pranešimui taikomas teisinis išsaugojimas, todėl jo ištrinti negalima.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Kad galėtum teikti pranešimus, turi užbaigti paskyros nustatymą.", "moderation_and_reports.unknown_report": "Nežinomas pranešimas.", "moderation_and_reports.user_is_not_banned": "Šis naudotojas nėra užblokuotas.", diff --git a/packages/errors/src/i18n/weblate/locales/nl.json b/packages/errors/src/i18n/weblate/locales/nl.json index 3dd7fb962..0077eda6f 100644 --- a/packages/errors/src/i18n/weblate/locales/nl.json +++ b/packages/errors/src/i18n/weblate/locales/nl.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "De melding is al afgehandeld.", "moderation_and_reports.report_banned": "Je bent uitgesloten van het indienen van meldingen.", "moderation_and_reports.report_flow_outdated": "Het meldingsformulier is gewijzigd terwijl je het invulde. Begin opnieuw met je melding.", + "moderation_and_reports.report_under_legal_hold": "Deze melding valt onder een juridische bewaarplicht en kan niet worden verwijderd.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Je moet de registratie van je account voltooien voordat je meldingen kunt indienen.", "moderation_and_reports.unknown_report": "Onbekende melding.", "moderation_and_reports.user_is_not_banned": "Deze gebruiker is niet verbannen.", diff --git a/packages/errors/src/i18n/weblate/locales/no.json b/packages/errors/src/i18n/weblate/locales/no.json index a578821a7..4f53e5d54 100644 --- a/packages/errors/src/i18n/weblate/locales/no.json +++ b/packages/errors/src/i18n/weblate/locales/no.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Rapporten er allerede behandlet.", "moderation_and_reports.report_banned": "Du er utestengt fra å sende rapporter.", "moderation_and_reports.report_flow_outdated": "Rapportskjemaet ble endret mens du fylte det ut. Start rapporten på nytt.", + "moderation_and_reports.report_under_legal_hold": "Denne rapporten er underlagt rettslig bevaring og kan ikke slettes.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Du må fullføre registreringen av kontoen din før du kan sende rapporter.", "moderation_and_reports.unknown_report": "Ukjent rapport.", "moderation_and_reports.user_is_not_banned": "Denne brukeren er ikke utestengt.", diff --git a/packages/errors/src/i18n/weblate/locales/pl.json b/packages/errors/src/i18n/weblate/locales/pl.json index b550ab856..244508849 100644 --- a/packages/errors/src/i18n/weblate/locales/pl.json +++ b/packages/errors/src/i18n/weblate/locales/pl.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Zgłoszenie zostało już rozpatrzone.", "moderation_and_reports.report_banned": "Zablokowano Ci możliwość przesyłania zgłoszeń.", "moderation_and_reports.report_flow_outdated": "Formularz zgłoszenia zmienił się podczas jego wypełniania. Rozpocznij zgłoszenie ponownie.", + "moderation_and_reports.report_under_legal_hold": "To zgłoszenie podlega prawnemu zabezpieczeniu i nie można go usunąć.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Dokończ rejestrację konta, zanim wyślesz zgłoszenia.", "moderation_and_reports.unknown_report": "Nieznane zgłoszenie.", "moderation_and_reports.user_is_not_banned": "Ten użytkownik nie ma bana.", diff --git a/packages/errors/src/i18n/weblate/locales/pt-BR.json b/packages/errors/src/i18n/weblate/locales/pt-BR.json index 1b0dedb69..c9b8e795c 100644 --- a/packages/errors/src/i18n/weblate/locales/pt-BR.json +++ b/packages/errors/src/i18n/weblate/locales/pt-BR.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "A denúncia já foi resolvida.", "moderation_and_reports.report_banned": "Você foi impedido de enviar denúncias.", "moderation_and_reports.report_flow_outdated": "O formulário de denúncia mudou enquanto você o preenchia. Comece a denúncia novamente.", + "moderation_and_reports.report_under_legal_hold": "Esta denúncia está sob retenção legal e não pode ser excluída.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Você precisa terminar a configuração da sua conta antes de fazer denúncias.", "moderation_and_reports.unknown_report": "Denúncia não encontrada.", "moderation_and_reports.user_is_not_banned": "Este usuário não está banido.", diff --git a/packages/errors/src/i18n/weblate/locales/ro.json b/packages/errors/src/i18n/weblate/locales/ro.json index 180e6c066..c7113467d 100644 --- a/packages/errors/src/i18n/weblate/locales/ro.json +++ b/packages/errors/src/i18n/weblate/locales/ro.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Raportul a fost deja rezolvat.", "moderation_and_reports.report_banned": "Ți s-a interzis să trimiți rapoarte.", "moderation_and_reports.report_flow_outdated": "Formularul de raportare s-a modificat în timp ce îl completai. Repornește raportarea.", + "moderation_and_reports.report_under_legal_hold": "Acest raport este supus unei rețineri legale și nu poate fi șters.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Trebuie să-ți finalizezi configurarea contului înainte să poți trimite rapoarte.", "moderation_and_reports.unknown_report": "Raport necunoscut.", "moderation_and_reports.user_is_not_banned": "Acest utilizator nu este banat.", diff --git a/packages/errors/src/i18n/weblate/locales/ru.json b/packages/errors/src/i18n/weblate/locales/ru.json index 8ac919354..2d8561863 100644 --- a/packages/errors/src/i18n/weblate/locales/ru.json +++ b/packages/errors/src/i18n/weblate/locales/ru.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Жалоба уже рассмотрена.", "moderation_and_reports.report_banned": "Ты забанен и не можешь отправлять жалобы.", "moderation_and_reports.report_flow_outdated": "Форма жалобы изменилась во время заполнения. Начни жалобу заново.", + "moderation_and_reports.report_under_legal_hold": "Эта жалоба находится на юридическом удержании и не может быть удалена.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Тебе нужно завершить настройку аккаунта, чтобы подавать жалобы.", "moderation_and_reports.unknown_report": "Жалоба не найдена.", "moderation_and_reports.user_is_not_banned": "Этот пользователь не забанен.", diff --git a/packages/errors/src/i18n/weblate/locales/sv-SE.json b/packages/errors/src/i18n/weblate/locales/sv-SE.json index c66f9c297..404762706 100644 --- a/packages/errors/src/i18n/weblate/locales/sv-SE.json +++ b/packages/errors/src/i18n/weblate/locales/sv-SE.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Anmälan har redan behandlats.", "moderation_and_reports.report_banned": "Du har förbjudits att skicka in anmälningar.", "moderation_and_reports.report_flow_outdated": "Anmälningsformuläret ändrades medan du fyllde i det. Börja om med anmälan.", + "moderation_and_reports.report_under_legal_hold": "Den här anmälan omfattas av ett rättsligt bevarandekrav och kan inte raderas.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Du måste slutföra konfigureringen av ditt konto innan du kan skicka in anmälningar.", "moderation_and_reports.unknown_report": "Okänd anmälan.", "moderation_and_reports.user_is_not_banned": "Den här användaren är inte bannlyst.", diff --git a/packages/errors/src/i18n/weblate/locales/th.json b/packages/errors/src/i18n/weblate/locales/th.json index e6875e319..230e625cf 100644 --- a/packages/errors/src/i18n/weblate/locales/th.json +++ b/packages/errors/src/i18n/weblate/locales/th.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "รายงานนี้ได้รับการดำเนินการแล้ว", "moderation_and_reports.report_banned": "คุณถูกแบนจากการส่งรายงาน", "moderation_and_reports.report_flow_outdated": "แบบฟอร์มรายงานมีการเปลี่ยนแปลงขณะที่คุณกำลังกรอกข้อมูลอยู่ โปรดเริ่มรายงานใหม่อีกครั้ง", + "moderation_and_reports.report_under_legal_hold": "รายงานนี้อยู่ระหว่างการระงับตามกฎหมายและไม่สามารถลบได้", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "คุณต้องตั้งค่าบัญชีให้เสร็จสมบูรณ์ก่อนจึงจะสามารถส่งรายงานได้", "moderation_and_reports.unknown_report": "ไม่พบรายงาน", "moderation_and_reports.user_is_not_banned": "ผู้ใช้รายนี้ไม่ได้ถูกแบน", diff --git a/packages/errors/src/i18n/weblate/locales/tr.json b/packages/errors/src/i18n/weblate/locales/tr.json index 6ddc1bcca..cbbcc0c71 100644 --- a/packages/errors/src/i18n/weblate/locales/tr.json +++ b/packages/errors/src/i18n/weblate/locales/tr.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Rapor zaten çözüldü.", "moderation_and_reports.report_banned": "Rapor göndermen yasaklandı.", "moderation_and_reports.report_flow_outdated": "Rapor formu sen doldururken değişti. Raporu yeniden başlat.", + "moderation_and_reports.report_under_legal_hold": "Bu rapor yasal saklama kapsamında olduğu için silinemez.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Rapor göndermeden önce hesap kurulumunu tamamlamalısın.", "moderation_and_reports.unknown_report": "Bilinmeyen rapor.", "moderation_and_reports.user_is_not_banned": "Bu kullanıcı yasaklı değil.", diff --git a/packages/errors/src/i18n/weblate/locales/uk.json b/packages/errors/src/i18n/weblate/locales/uk.json index bd1a66752..4eee63be8 100644 --- a/packages/errors/src/i18n/weblate/locales/uk.json +++ b/packages/errors/src/i18n/weblate/locales/uk.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Звіт вже розглянуто.", "moderation_and_reports.report_banned": "Тобі заборонено надсилати звіти.", "moderation_and_reports.report_flow_outdated": "Форма звіту змінилася під час заповнення. Почни звіт заново.", + "moderation_and_reports.report_under_legal_hold": "Цей звіт перебуває під юридичним утриманням, і його не можна видалити.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Тобі потрібно завершити налаштування акаунта, щоб надсилати звіти.", "moderation_and_reports.unknown_report": "Невідомий звіт.", "moderation_and_reports.user_is_not_banned": "Цей користувач не забанений.", diff --git a/packages/errors/src/i18n/weblate/locales/vi.json b/packages/errors/src/i18n/weblate/locales/vi.json index 48bb15a3b..150babf04 100644 --- a/packages/errors/src/i18n/weblate/locales/vi.json +++ b/packages/errors/src/i18n/weblate/locales/vi.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Báo cáo đã được giải quyết.", "moderation_and_reports.report_banned": "Bạn đã bị cấm gửi báo cáo.", "moderation_and_reports.report_flow_outdated": "Biểu mẫu báo cáo đã thay đổi trong khi bạn đang điền. Hãy bắt đầu lại báo cáo.", + "moderation_and_reports.report_under_legal_hold": "Báo cáo này đang bị lưu giữ theo yêu cầu pháp lý và không thể xóa.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "Bạn cần hoàn tất thiết lập tài khoản trước khi có thể gửi báo cáo.", "moderation_and_reports.unknown_report": "Báo cáo không xác định.", "moderation_and_reports.user_is_not_banned": "Người dùng này không bị cấm.", diff --git a/packages/errors/src/i18n/weblate/locales/zh-CN.json b/packages/errors/src/i18n/weblate/locales/zh-CN.json index b75442e1a..638cdc819 100644 --- a/packages/errors/src/i18n/weblate/locales/zh-CN.json +++ b/packages/errors/src/i18n/weblate/locales/zh-CN.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "举报已解决。", "moderation_and_reports.report_banned": "你已被禁止提交举报。", "moderation_and_reports.report_flow_outdated": "举报表单在你填写过程中已更新。请重新开始举报。", + "moderation_and_reports.report_under_legal_hold": "该举报处于法律保全状态,无法删除。", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "你需要完成账号设置才能提交举报。", "moderation_and_reports.unknown_report": "未知举报。", "moderation_and_reports.user_is_not_banned": "此用户未被封禁。", diff --git a/packages/errors/src/i18n/weblate/locales/zh-TW.json b/packages/errors/src/i18n/weblate/locales/zh-TW.json index ae431f2e4..50524a74c 100644 --- a/packages/errors/src/i18n/weblate/locales/zh-TW.json +++ b/packages/errors/src/i18n/weblate/locales/zh-TW.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "檢舉已解決。", "moderation_and_reports.report_banned": "你已被禁止送出檢舉。", "moderation_and_reports.report_flow_outdated": "檢舉表單在你填寫時已變更。請重新開始檢舉。", + "moderation_and_reports.report_under_legal_hold": "此檢舉受法律保全約束,無法刪除。", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "你必須完成帳號設定才能送出檢舉。", "moderation_and_reports.unknown_report": "未知的檢舉。", "moderation_and_reports.user_is_not_banned": "此使用者未被封鎖。", diff --git a/packages/errors/src/i18n/weblate/messages.json b/packages/errors/src/i18n/weblate/messages.json index 1ac6ecdcb..ffcdaf658 100644 --- a/packages/errors/src/i18n/weblate/messages.json +++ b/packages/errors/src/i18n/weblate/messages.json @@ -364,6 +364,7 @@ "moderation_and_reports.report_already_resolved": "Report already resolved.", "moderation_and_reports.report_banned": "You've been banned from submitting reports.", "moderation_and_reports.report_flow_outdated": "The report form changed while you were filling it in. Start the report again.", + "moderation_and_reports.report_under_legal_hold": "This report is under a legal hold and can't be deleted.", "moderation_and_reports.unclaimed_account_cannot_submit_reports": "You need to complete your account setup before you can submit reports.", "moderation_and_reports.unknown_report": "Unknown report.", "moderation_and_reports.user_is_not_banned": "This user isn't banned.",