feat(reports): delete reports and configure retention (#3338)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:41:47 +02:00
1 parent 93d1f8af46
commit 82abd03a37
106 files changed
+1457 -170

No files matched your search

+2
View File
@@ -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,
+13
View File
@@ -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<'_>,
+7 -3
View File
@@ -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)]
+11
View File
@@ -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,
+74
View File
@@ -61,6 +61,16 @@ struct LegalHoldForm {
clear: Option<String>,
}
#[derive(Deserialize)]
struct DeleteForm {
#[serde(default)]
_csrf: Option<String>,
#[serde(default)]
confirm: Option<String>,
#[serde(default)]
audit_log_reason: Option<String>,
}
#[derive(Deserialize)]
struct ResolveForm {
#[serde(default)]
@@ -88,6 +98,10 @@ pub fn router() -> Router<AppState> {
"/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<AppState>,
auth: axum::Extension<AuthContext>,
Path(report_id): Path<String>,
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";
@@ -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("<form"), "{markup}");
let lapsed = report(json!({
"legal_hold_until": "2026-01-31T23:59:59.999Z",
"legal_hold_reason": "Court order 41"
}));
let markup = delete_report_card(&config, &deleter, &lapsed, "csrf").into_string();
assert!(
markup.contains(r#"data-report-delete="available""#),
"{markup}"
);
assert!(
delete_report_card(&config, &resolver, &open, "csrf")
.into_string()
.is_empty()
);
let page = render_page(&deleter, &open);
assert!(page.contains("data-report-delete-form"), "{page}");
let page = render_page(&resolver, &open);
assert!(!page.contains("data-report-delete"), "{page}");
}
}