Compare commits

...
Author SHA1 Message Date
Hampus 49a1ffabbe feat(admin): add internal notes to reports (#3510) 2026-10-17 05:50:33 +02:00
Hampus 2d8d130be1 feat(api): allow routing all email through fallback (#3509) 2026-10-17 04:16:08 +02:00
Hampus d564e01518 chore(deps): bump zip to 9.0.4 (#3508) 2026-10-17 02:54:04 +02:00
Hampus 557efe1c7d fix(api): add custom headers to email fallback SMTP (#3507) 2026-10-17 02:38:59 +02:00
Hampus 0b1ddc2695 feat(api): add domain-routed fallback email provider (#3506) 2026-10-17 01:45:19 +02:00
NanakusaandHampus c195e2b5de feat(app): double click message actions (#1546)
Co-authored-by: Hampus <[email protected]>
2026-10-16 14:55:56 +02:00
Nanakusa 9e15194d6f fix(desktop): use same connection for sending/recieving notifications (#3505) 2026-10-16 14:41:45 +02:00
Hampus 275cc73f7a fix(app): keep brand colour text readable on every surface (#3504) 2026-10-16 03:06:24 +02:00
Hampus 3224f2113a fix(voice): judge local speaking from the sent audio (#3503) 2026-10-15 23:05:02 +02:00
Hampus b9ea837c75 fix(app): treat a black instance theme colour as unset (#3502) 2026-10-15 23:04:40 +02:00
Hampus 41da6b5985 feat(app): align default keybinds with Discord (#3501) 2026-10-15 23:04:13 +02:00
Hampus 9000a9f4c7 feat(admin): add warning given report outcome (#3500) 2026-10-15 23:03:44 +02:00
Hampus 61a4e3895a feat(admin): required marks and report prefill on decisions (#3499) 2026-10-15 23:03:24 +02:00
Hampus 86c78427d8 feat(admin): copy message link and ids in message tools (#3498) 2026-10-15 23:02:57 +02:00
Hampus 8beccdfe54 fix(app): request webp forum card thumbnails for video posts (#3497) 2026-10-15 21:22:24 +02:00
Hampus 8f4c966d3a fix(desktop): keep the KDE tray icon full size (#3496) 2026-10-15 16:49:56 +02:00
Hampus c424656bad feat(desktop): crisp taskbar badges and a dark badge option (#3495) 2026-10-15 16:49:34 +02:00
Hampus 4b8ce8bf63 fix(desktop): trust CAs in the windows intermediate store (#3494) 2026-10-15 16:48:55 +02:00
Hampus 4f88872a3c feat(reports): keep reports until resolved, then by outcome (#3493) 2026-10-15 16:40:51 +02:00
327 changed files with 6147 additions and 1174 deletions

No files matched your search

Generated
+16 -3
View File
@@ -2986,6 +2986,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nkeys"
version = "0.4.5"
@@ -4613,7 +4625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -5763,14 +5775,15 @@ dependencies = [
[[package]]
name = "zip"
version = "8.6.0"
version = "9.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"nix",
"typed-path",
"zopfli",
]
+2
View File
@@ -130,6 +130,8 @@ secrets:
# FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64: ""
# SMTP password for outgoing email.
# FLUXER_EMAIL_SMTP_PASSWORD: ""
# SMTP password of the email fallback provider.
# FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: ""
# Secret that authenticates email provider webhooks.
# FLUXER_EMAIL_WEBHOOK_SECRET: ""
# Stripe secret key.
+15 -5
View File
@@ -325,6 +325,18 @@ FLUXER_EMAIL_SMTP_TLS_MODE=
# The older switch, read only while the mode above is empty. true is implicit
# and false is opportunistic.
#FLUXER_EMAIL_SMTP_SECURE=false
# A second SMTP provider for mail to the listed recipient domains, comma
# separated and matched exactly. Everything else goes through the provider
# above. The port falls back to 587 and the mode works like the one above.
#FLUXER_EMAIL_FALLBACK_DOMAINS=gmail.com,googlemail.com
#FLUXER_EMAIL_FALLBACK_SMTP_HOST=
#FLUXER_EMAIL_FALLBACK_SMTP_PORT=587
#FLUXER_EMAIL_FALLBACK_SMTP_USERNAME=
#FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD=
#FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE=
# Extra headers on every fallback message, as a JSON object of header name to
# string value. Use single quotes so the shell keeps the JSON intact.
#FLUXER_EMAIL_FALLBACK_SMTP_HEADERS='{"X-Example-Tracking":"off"}'
#FLUXER_EMAIL_WEBHOOK_SECRET=
FLUXER_DISCOVERY_ENABLED=true
@@ -353,12 +365,10 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_AUTO_JOIN_INVITE_CODE=
#FLUXER_DELETION_GRACE_PERIOD_HOURS=336
# 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.
# Report retention. A daily job deletes each resolved report this many days
# after it was resolved, with its evidence copies and search entry, unless a
# legal hold is set. Pending reports are kept until they are resolved.
#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.
+7 -1
View File
@@ -126,6 +126,13 @@ x-fluxer-env: &fluxer-env
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
FLUXER_EMAIL_SMTP_TLS_MODE: ${FLUXER_EMAIL_SMTP_TLS_MODE:-}
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-}
FLUXER_EMAIL_FALLBACK_DOMAINS: ${FLUXER_EMAIL_FALLBACK_DOMAINS:-}
FLUXER_EMAIL_FALLBACK_SMTP_HOST: ${FLUXER_EMAIL_FALLBACK_SMTP_HOST:-}
FLUXER_EMAIL_FALLBACK_SMTP_PORT: ${FLUXER_EMAIL_FALLBACK_SMTP_PORT:-}
FLUXER_EMAIL_FALLBACK_SMTP_USERNAME: ${FLUXER_EMAIL_FALLBACK_SMTP_USERNAME:-}
FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: ${FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD:-}
FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE: ${FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE:-}
FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: ${FLUXER_EMAIL_FALLBACK_SMTP_HEADERS:-}
FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-}
FLUXER_STRIPE_SECRET_KEY: ${FLUXER_STRIPE_SECRET_KEY:-}
@@ -159,7 +166,6 @@ x-fluxer-env: &fluxer-env
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:-}
+170 -4
View File
@@ -5655,7 +5655,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Retrieves detailed information about a specific report including content, reporter, reason, and the message context and profile snapshot captured when it was filed. Requires REPORT_VIEW permission.",
"description": "Retrieves detailed information about a specific report including content, reporter, reason, the message context and profile snapshot captured when it was filed, and its internal notes. Requires REPORT_VIEW permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -5782,7 +5782,7 @@
"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.",
"description": "Deletes a report now: its record with the captured message context and profile snapshot, its notes, 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": [
{
@@ -5867,6 +5867,138 @@
}
}
},
"/admin/reports/{report_id}/notes": {
"get": {
"operationId": "list_admin_report_notes",
"summary": "List report notes",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportNoteListResponse"}}}
},
"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": "Lists the internal notes on a report, oldest first. Notes are staff only and are deleted with the report. Requires REPORT_VIEW 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"
}
]
},
"post": {
"operationId": "annotate_admin_report",
"summary": "Add a note to a report",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportNoteResponse"}}}
},
"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": "Appends an internal note to a report. Notes are staff only, never change and are deleted with the report. Creates audit log entry. Requires REPORT_RESOLVE 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"
}
],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportNoteRequest"}}}
}
}
},
"/admin/search/index-refreshes/{job_id}": {
"get": {
"operationId": "get_admin_search_index_refresh",
@@ -10969,6 +11101,34 @@
}
]
},
"ReportNoteRequest": {
"type": "object",
"properties": {"body": {"description": "The internal note. Staff only", "type": "string"}},
"required": ["body"]
},
"ReportNoteResponse": {
"type": "object",
"properties": {
"note_id": {"$ref": "#/components/schemas/SnowflakeStringType"},
"author_id": {"$ref": "#/components/schemas/SnowflakeStringType"},
"body": {"type": "string"},
"created_at": {"type": "string"}
},
"required": ["note_id", "author_id", "body", "created_at"],
"additionalProperties": false
},
"ReportNoteListResponse": {
"type": "object",
"properties": {
"notes": {
"type": "array",
"items": {"$ref": "#/components/schemas/ReportNoteResponse"},
"description": "The notes on the report, oldest first"
}
},
"required": ["notes"],
"additionalProperties": false
},
"ReportLegalHoldRequest": {
"type": "object",
"properties": {
@@ -11027,14 +11187,15 @@
},
"outcome": {
"description": "What happened to the reported content or account. Required for actioned, where no_violation is refused. Optional for no_violation, where it can only be no_violation. Refused for duplicate. The reporter is told it",
"x-enumNames": ["removed", "restricted", "action_taken", "no_violation"],
"x-enumNames": ["removed", "warned", "restricted", "action_taken", "no_violation"],
"x-enumDescriptions": [
"The reported content, profile field or community was removed",
"The account responsible for the reported item was warned",
"The reported account was restricted and the content was left up",
"Action was taken under the rules, and the reporter is not told which",
"The reported content or account does not break the rules and was left as it is"
],
"enum": ["removed", "restricted", "action_taken", "no_violation"],
"enum": ["removed", "warned", "restricted", "action_taken", "no_violation"],
"type": "string"
},
"duplicate_of": {
@@ -11405,6 +11566,11 @@
"nullable": true,
"type": "string"
},
"notes": {
"description": "Internal staff notes on the report, oldest first. Returned by the report detail only.",
"type": "array",
"items": {"$ref": "#/components/schemas/ReportNoteResponse"}
},
"reason": {"nullable": true, "type": "string"},
"reason_label": {"nullable": true, "type": "string"},
"reason_highest_priority": {"nullable": true, "type": "boolean"},
+16 -2
View File
@@ -5,8 +5,8 @@ use reqwest::Method;
use crate::api::generated::{
snowflake,
types::{
UpdateReportRequest, UpdateReportRequestOutcome, UpdateReportRequestResolution,
UpdateReportRequestStatus,
ReportNoteRequest, ReportNoteResponse, UpdateReportRequest, UpdateReportRequestOutcome,
UpdateReportRequestResolution, UpdateReportRequestStatus,
},
};
@@ -94,6 +94,20 @@ impl AdminApiClient {
.await
}
pub async fn annotate_report(
&self,
report_id: &str,
body: String,
) -> ApiResult<ReportNoteResponse> {
self.send(
Method::POST,
&format!("/admin/reports/{}/notes", urlencoding::encode(report_id)),
Some(&ReportNoteRequest { body }),
None,
)
.await
}
pub async fn delete_report(
&self,
report_id: &str,
+10
View File
@@ -171,6 +171,16 @@ pub struct ReportEntry {
pub legal_hold_until: Option<String>,
#[serde(default)]
pub legal_hold_reason: Option<String>,
#[serde(default)]
pub notes: Vec<ReportNoteEntry>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReportNoteEntry {
pub note_id: String,
pub author_id: String,
pub body: String,
pub created_at: String,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+70 -1
View File
@@ -62,6 +62,14 @@ struct LegalHoldForm {
clear: Option<String>,
}
#[derive(Deserialize)]
struct NoteForm {
#[serde(default)]
_csrf: Option<String>,
#[serde(default)]
body: Option<String>,
}
#[derive(Deserialize)]
struct DeleteForm {
#[serde(default)]
@@ -99,6 +107,10 @@ pub fn router() -> Router<AppState> {
"/reports/{report_id}/resolve",
axum::routing::post(report_resolve),
)
.route(
"/reports/{report_id}/notes",
axum::routing::post(report_note),
)
.route(
"/reports/{report_id}/legal-hold",
axum::routing::post(report_legal_hold),
@@ -215,12 +227,21 @@ async fn report_detail(
.log_error("load report detail");
match report {
Some(report) => {
let live = load_live_profile(&client, &auth.0, &report).await;
let note_author_ids = note_author_ids(&report);
let (live, note_authors) =
tokio::join!(load_live_profile(&client, &auth.0, &report), async {
client
.lookup_users_by_ids(&note_author_ids)
.await
.log_error("load report note authors")
.unwrap_or_default()
});
let markup = templates::pages::report_detail::report_detail_page(
config,
&auth.0,
&report,
&live,
&note_authors,
&csrf_token,
is_detail_fragment,
);
@@ -233,6 +254,17 @@ async fn report_detail(
}
}
fn note_author_ids(report: &ReportEntry) -> Vec<String> {
let mut ids: Vec<String> = report
.notes
.iter()
.map(|note| note.author_id.clone())
.collect();
ids.sort_unstable();
ids.dedup();
ids
}
async fn load_live_profile(
client: &AdminApiClient,
auth: &AuthContext,
@@ -381,6 +413,43 @@ async fn report_resolve(
flash::redirect_with_flash(&back, flash, config.secure_cookies())
}
async fn report_note(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
Path(report_id): Path<String>,
request: Request,
) -> Response {
let config = state.config();
let back = format!("{}/reports/{report_id}", config.base_path);
let form: NoteForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
tracing::warn!(%error, report_id, "failed to parse report note form");
return flash::redirect_with_flash(
&back,
FlashData::error("Invalid form data"),
config.secure_cookies(),
);
}
};
let Some(body) = clean_string(form.body.as_deref().unwrap_or("")) else {
return flash::redirect_with_flash(
&back,
FlashData::error("Write a note first"),
config.secure_cookies(),
);
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let flash = match client.annotate_report(&report_id, body).await {
Ok(_) => FlashData::success("Note added"),
Err(error) => {
tracing::warn!(%error, report_id, "admin API request failed: add report note");
FlashData::error("Failed to add the note")
}
};
flash::redirect_with_flash(&back, flash, config.secure_cookies())
}
const RESOLUTION_REQUIRED: &str = "Choose a resolution";
const OUTCOME_REQUIRED: &str = "Choose what happened to the reported content or account";
const DUPLICATE_OF_REQUIRED: &str = "Enter the ID of the report this one repeats";
@@ -98,11 +98,23 @@ pub fn field_label(for_id: &str, text: &str) -> Markup {
}
}
pub fn required_mark() -> Markup {
html! {
span class="text-red-500 ml-0.5" { "*" }
}
}
fn required_label(for_id: &str, text: &str) -> Markup {
html! {
label for=(for_id) class=(FIELD_LABEL_CLASS) { (text) (required_mark()) }
}
}
pub fn internal_note_field(id_prefix: &str, help: &str) -> Markup {
let id = format!("{id_prefix}-internal-note");
html! {
div class="space-y-1" {
(field_label(&id, "Internal note"))
(required_label(&id, "Internal note"))
input type="text" id=(id) name="internal_note" required
maxlength=(INTERNAL_NOTE_MAX_CHARS) class=(FIELD_INPUT_CLASS);
p class=(FIELD_HELP_CLASS) { (help) }
@@ -147,14 +159,25 @@ pub fn statement_field(id_prefix: &str, help: &str) -> Markup {
}
pub fn radio_group(legend: &str, name: &str, id_prefix: &str, options: &[(&str, &str)]) -> Markup {
checked_radio_group(legend, name, id_prefix, options, None)
}
fn checked_radio_group(
legend: &str,
name: &str,
id_prefix: &str,
options: &[(&str, &str)],
checked: Option<&str>,
) -> Markup {
html! {
fieldset class="space-y-1" {
legend class=(FIELD_LABEL_CLASS) { (legend) }
legend class=(FIELD_LABEL_CLASS) { (legend) (required_mark()) }
div class="flex flex-wrap gap-x-4 gap-y-1" {
@for (value, label) in options {
@let id = format!("{id_prefix}-{name}-{value}");
div class="flex items-center gap-2" {
input type="radio" id=(id) name=(name) value=(value) required
checked[checked == Some(*value)]
class="h-4 w-4 accent-neutral-900";
label for=(id) class="text-sm text-neutral-900" { (label) }
}
@@ -181,7 +204,7 @@ pub fn reason_select(
let id = format!("{id_prefix}-reason");
html! {
div class="space-y-1" {
(field_label(&id, "Reason"))
(required_label(&id, "Reason"))
select id=(id) name="reason_code" required class=(FIELD_INPUT_CLASS) {
option value="" disabled selected { "Choose a reason" }
@for (handling, group) in HANDLING_GROUPS
@@ -226,13 +249,14 @@ pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup {
let reports_id = format!("{prefix}-report-ids");
let messages_id = format!("{prefix}-evidence");
let rights_holder_id = format!("{prefix}-rights-holder");
let from_report = !fields.report_ids.is_empty();
html! {
div class="space-y-4" data-enforcement-fields="" {
(reason_select(prefix, fields.kind, fields.allow_restricted))
(radio_group("How did we find this?", "source", prefix, &[
(checked_radio_group("How did we find this?", "source", prefix, &[
("report", "A report"),
("own_review", "Our own review"),
]))
], from_report.then_some("report")))
div class="space-y-1" {
(radio_group("Automated tools", "automation", prefix, &[
("none", "Not involved"),
@@ -243,7 +267,10 @@ pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup {
}
}
div class="space-y-1" {
(field_label(&reports_id, "Reports"))
label for=(reports_id) class=(FIELD_LABEL_CLASS) {
"Reports"
span data-show-for-source="report" hidden[!from_report] { (required_mark()) }
}
textarea id=(reports_id) name="report_ids" rows="2"
placeholder="One report ID per line" class=(FIELD_INPUT_CLASS) {
(fields.report_ids.join("\n"))
@@ -257,13 +284,19 @@ pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup {
class=(FIELD_INPUT_CLASS) {
(messages.join("\n"))
}
p class=(FIELD_HELP_CLASS) data-hide-for-codes=(restricted_codes) {
"Messages this user sent in a linked report are included automatically. Without any messages, add a note to the user."
}
p class=(FIELD_HELP_CLASS) data-show-for-codes=(restricted_codes) hidden {
"Messages this user sent in a linked report are included automatically. At least one message is required."
}
}
}
div data-hide-for-codes=(restricted_codes) {
(statement_field(prefix, "Shown to the user. Never name or describe other people. Not sent for child safety or violent extremism decisions."))
}
div class="space-y-1" data-show-for-codes=(COPYRIGHT_REASON_CODE) hidden {
(field_label(&rights_holder_id, "Rights holder or right concerned"))
(required_label(&rights_holder_id, "Rights holder or right concerned"))
input type="text" id=(rights_holder_id) name="rights_holder" required disabled
maxlength=(RIGHTS_HOLDER_MAX_CHARS) class=(FIELD_INPUT_CLASS);
p class=(FIELD_HELP_CLASS) {
@@ -626,7 +659,43 @@ mod tests {
assert!(html.contains(marker), "missing {marker}\n{html}");
}
assert!(!html.contains(r#"name="automation" value="automated""#));
assert!(!html.contains(r#"checked"#));
assert_eq!(html.matches("checked").count(), 1);
assert!(html.contains(r#"name="source" value="report" required checked"#));
assert!(html.contains(r#"<span data-show-for-source="report"><span"#));
}
#[test]
fn fieldset_without_a_report_leaves_the_source_open() {
let html = enforcement_fields(&DecisionFields {
id_prefix: "limit",
kind: EnforcementKindType::FeatureLimit,
report_ids: &[],
messages: Some(&[]),
user_flags: None,
allow_restricted: true,
})
.into_string();
assert!(!html.contains("checked"));
assert!(html.contains(r#"<span data-show-for-source="report" hidden><span"#));
}
#[test]
fn required_fields_carry_a_mark() {
let html = render(EnforcementKindType::FeatureLimit, 0);
let mark = r#"<span class="text-red-500 ml-0.5">*</span>"#;
for label in [
"Reason",
"How did we find this?",
"Automated tools",
"Internal note",
"Rights holder or right concerned",
] {
assert!(html.contains(&format!("{label}{mark}")), "unmarked {label}");
}
for label in ["Messages<", "Note to the user<"] {
assert!(html.contains(label), "marked {label}");
}
assert_eq!(html.matches(mark).count(), 6);
}
#[test]
@@ -38,6 +38,7 @@ pub struct Message {
pub channel_nsfw: Option<bool>,
pub channel_content_warning_level: Option<i32>,
pub channel_content_warning_text: Option<String>,
pub guild_id: Option<String>,
pub guild_nsfw: Option<bool>,
pub attachments: Vec<Attachment>,
pub missing_attachments: Vec<MissingAttachment>,
@@ -85,6 +86,7 @@ fn message_from_value(value: &Value) -> Message {
channel_content_warning_text: value["channel_content_warning_text"]
.as_str()
.map(ToOwned::to_owned),
guild_id: value_id(&value["guild_id"]),
guild_nsfw: value["guild_nsfw"].as_bool(),
attachments,
missing_attachments,
@@ -110,6 +110,48 @@ fn render_missing_attachments(msg: &Message) -> Markup {
}
}
fn copy_button(value: &str, label: &str) -> Markup {
html! {
button type="button"
class="cursor-pointer text-blue-600 hover:underline"
data-copy-value=(value)
onclick="window.__adminCopyToClipboard && window.__adminCopyToClipboard(this.dataset.copyValue, this, 'Copied')" {
(label)
}
}
}
fn message_meta(msg: &Message, jump_link: Option<&str>, take_action: Option<&str>) -> Markup {
html! {
div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-neutral-400 text-xs" {
span class="select-all" { (msg.id) }
@if let Some(href) = take_action {
a href=(href) class="text-blue-600 hover:underline" { "Take action" }
}
@if let Some(link) = jump_link {
(copy_button(link, "Copy link"))
}
(copy_button(&msg.id, "Copy message ID"))
@if !msg.channel_id.is_empty() {
(copy_button(&msg.channel_id, "Copy channel ID"))
}
}
}
}
fn message_jump_link(web_app_endpoint: &str, msg: &Message) -> Option<String> {
if msg.channel_id.is_empty() {
return None;
}
Some(format!(
"{}/channels/{}/{}/{}",
web_app_endpoint.trim_end_matches('/'),
msg.guild_id.as_deref().unwrap_or("@me"),
msg.channel_id,
msg.id
))
}
fn message_row(
base_path: &str,
avatar_url: &str,
@@ -117,7 +159,7 @@ fn message_row(
include_delete: bool,
is_highlighted: bool,
is_grouped: bool,
take_action: Option<&str>,
meta: &Markup,
) -> Markup {
let hover = if is_highlighted {
" hover:bg-amber-100"
@@ -151,12 +193,7 @@ fn message_row(
(render_other_attachments(msg, has_content_or_images))
}
(render_missing_attachments(msg))
div class="mt-1 text-neutral-400 text-xs" {
span class="" { (msg.id) }
@if let Some(href) = take_action {
a href=(href) class="ml-2 text-blue-600 hover:underline" { "Take action" }
}
}
(meta)
@if include_delete && !has_images {
div class="absolute top-0 right-2 hidden group-hover:block" {
button type="button"
@@ -237,12 +274,7 @@ fn message_row(
(render_other_attachments(msg, has_content_or_images))
}
(render_missing_attachments(msg))
div class="mt-1 text-neutral-400 text-xs" {
span class="" { (msg.id) }
@if let Some(href) = take_action {
a href=(href) class="ml-2 text-blue-600 hover:underline" { "Take action" }
}
}
(meta)
}
@if include_delete && !has_images {
div class="absolute top-1 right-2 hidden group-hover:block" {
@@ -287,10 +319,12 @@ pub fn message_list(
action.report_id,
Some((&msg.channel_id, &msg.id)),
));
@let jump_link = message_jump_link(&config.web_app_endpoint, msg);
@let meta = message_meta(msg, jump_link.as_deref(), take_action_href.as_deref());
(message_row(
base_path, &avatar_url, msg,
include_delete, is_highlighted, is_grouped,
take_action_href.as_deref(),
&meta,
))
}
}
@@ -319,3 +353,50 @@ const MESSAGE_DELETE_PREFILL_SCRIPT: &str = r#"(function() {
form.querySelector('[name="basis"]').focus({preventScroll: true});
});
})();"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::templates::components::message_data::ordered_messages;
use serde_json::json;
fn message(guild_id: serde_json::Value, channel_id: serde_json::Value) -> Message {
ordered_messages(&[json!({
"id": "1560373081531219968",
"channel_id": channel_id,
"guild_id": guild_id,
"author_id": "1",
"author_username": "someone",
})])
.remove(0)
}
#[test]
fn jump_link_uses_guild_id_and_web_app_endpoint() {
let msg = message(json!("42"), json!("7"));
assert_eq!(
message_jump_link("https://web.fluxer.example/", &msg).as_deref(),
Some("https://web.fluxer.example/channels/42/7/1560373081531219968")
);
}
#[test]
fn jump_link_falls_back_to_me_for_dms() {
let msg = message(json!(null), json!("7"));
assert_eq!(
message_jump_link("https://web.fluxer.example", &msg).as_deref(),
Some("https://web.fluxer.example/channels/@me/7/1560373081531219968")
);
}
#[test]
fn meta_without_channel_skips_link_and_channel_copy() {
let msg = message(json!(null), json!(null));
assert_eq!(message_jump_link("https://web.fluxer.example", &msg), None);
let html = message_meta(&msg, None, None).into_string();
assert!(html.contains(r#"data-copy-value="1560373081531219968""#));
assert!(html.contains("Copy message ID"));
assert!(!html.contains("Copy link"));
assert!(!html.contains("Copy channel ID"));
}
}
@@ -22,6 +22,7 @@ pub mod report_category;
pub mod report_webhook;
pub mod resource_link;
pub mod section_card;
pub mod staff_notes;
pub mod table;
pub mod tooltip;
pub mod user_display;
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
templates::components::{
enforcement_fields::{FIELD_HELP_CLASS, FIELD_INPUT_CLASS, field_label},
form::{csrf_input, form_actions, submit_button},
section_card::section_card_simple,
},
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
const NOTE_MAX_CHARS: usize = 2000;
pub struct StaffNote<'a> {
pub note_id: &'a str,
pub author: Markup,
pub body: &'a str,
pub created_at: &'a str,
}
pub struct StaffNoteForm<'a> {
pub action: String,
pub refresh_target: &'a str,
pub field_id: String,
pub help: &'a str,
pub csrf_token: &'a str,
}
pub fn staff_notes_section(notes: &[StaffNote], form: Option<StaffNoteForm>) -> Markup {
html! {
(section_card_simple("Notes", html! {
div class="space-y-4" {
@if notes.is_empty() {
p class="text-sm text-neutral-500" { "No notes." }
} @else {
ol class="space-y-3" {
@for note in notes {
li class="space-y-1" data-note=(note.note_id) {
p class="text-xs text-neutral-500" {
(note.author)
" on " (format_admin_timestamp(note.created_at))
}
p class="whitespace-pre-wrap break-words text-sm text-neutral-900" { (note.body) }
}
}
}
}
@if let Some(form) = form {
form method="post" action=(form.action)
data-admin-refresh-on-success=(form.refresh_target) {
(csrf_input(form.csrf_token))
div class="space-y-3" {
div class="space-y-1" {
(field_label(&form.field_id, "Add a note"))
textarea id=(form.field_id) name="body" rows="2" required maxlength=(NOTE_MAX_CHARS)
class=(FIELD_INPUT_CLASS) {}
p class=(FIELD_HELP_CLASS) { (form.help) }
}
(form_actions(submit_button("Add note")))
}
}
}
}
}))
}
}
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::user_tag::user_tag;
use crate::{api::types::AdminUser, utils::user_tag::user_tag};
use maud::{Markup, html};
pub const SYSTEM_USER_ID: &str = "0";
pub fn format_user_display(
global_name: Option<&str>,
@@ -16,3 +19,29 @@ pub fn format_user_display(
_ => "Unknown".to_owned(),
}
}
pub fn user_name(users: &[AdminUser], id: &str) -> String {
if id == SYSTEM_USER_ID {
return "System".to_owned();
}
users.iter().find(|user| user.id == id).map_or_else(
|| id.to_owned(),
|user| {
format_user_display(
user.global_name.as_deref(),
Some(&user.username),
Some(&user.discriminator),
user.bot,
)
},
)
}
pub fn user_link(base: &str, users: &[AdminUser], id: &str) -> Markup {
if id == SYSTEM_USER_ID {
return html! { "System" };
}
html! {
a href={(base) "/users/" (id)} class="text-blue-600 hover:underline" { (user_name(users, id)) }
}
}
+1 -1
View File
@@ -663,7 +663,7 @@ pub const ENFORCEMENT_FIELDS_SCRIPT: &str = r#"
syncReason(form, control.value);
} else if (control instanceof HTMLSelectElement && (control.name === 'resolution' || control.name === 'duration_hours')) {
syncChoice(form, control.name, control.value);
} else if (control instanceof HTMLInputElement && control.type === 'radio' && (control.name === 'basis' || control.name === 'limit')) {
} else if (control instanceof HTMLInputElement && control.type === 'radio' && (control.name === 'basis' || control.name === 'limit' || control.name === 'source')) {
syncChoice(form, control.name, control.value);
}
});
@@ -29,7 +29,8 @@ pub fn action_badge_variant(action: &str) -> BadgeVariant {
| "set_acls"
| "update_settings"
| "annotate_ban"
| "annotate_enforcement_decision" => BadgeVariant::Info,
| "annotate_enforcement_decision"
| "annotate_report" => BadgeVariant::Info,
"delete_message" | "warn_user" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
}
@@ -23,7 +23,8 @@ use crate::{
form::{csrf_input, form_actions, submit_button},
page_container::{page_header, page_header_with_back},
section_card::section_card_simple,
user_display::format_user_display,
staff_notes::{StaffNote, StaffNoteForm, staff_notes_section},
user_display::{SYSTEM_USER_ID, user_link},
},
layout::admin_layout,
},
@@ -31,8 +32,6 @@ use crate::{
};
use maud::{Markup, html};
const SYSTEM_USER_ID: &str = "0";
const NOTE_MAX_CHARS: usize = 2000;
const CORRECTION_LINE: &str = "If this decision was reported to an authority, file a correction.";
const DECISIONS_PAGE_KEY: &str = "decisions";
const PERMANENT_SUSPENSION_THRESHOLD_YEARS: i32 = 50;
@@ -223,32 +222,6 @@ pub fn end_decision_form(form: &EndDecisionForm<'_>) -> Markup {
}
}
fn user_name(users: &[AdminUser], id: &str) -> String {
if id == SYSTEM_USER_ID {
return "System".to_owned();
}
users.iter().find(|user| user.id == id).map_or_else(
|| id.to_owned(),
|user| {
format_user_display(
user.global_name.as_deref(),
Some(&user.username),
Some(&user.discriminator),
user.bot,
)
},
)
}
fn user_link(base: &str, users: &[AdminUser], id: &str) -> Markup {
if id == SYSTEM_USER_ID {
return html! { "System" };
}
html! {
a href={(base) "/users/" (id)} class="text-blue-600 hover:underline" { (user_name(users, id)) }
}
}
fn field(label: &str, value: Markup) -> Markup {
html! {
div class="flex min-w-0 flex-col gap-1" {
@@ -527,44 +500,29 @@ fn notes_section(
can_annotate: bool,
csrf_token: &str,
) -> Markup {
let note_id = format!("note-{}", decision.decision_id.as_str());
html! {
(section_card_simple("Notes", html! {
div class="space-y-4" {
@if decision.notes.is_empty() {
p class="text-sm text-neutral-500" { "No notes." }
} @else {
ol class="space-y-3" {
@for note in &decision.notes {
li class="space-y-1" data-note=(note.note_id.as_str()) {
p class="text-xs text-neutral-500" {
(user_link(base, users, &note.author_id))
" on " (format_admin_timestamp(&note.created_at))
}
p class="whitespace-pre-wrap break-words text-sm text-neutral-900" { (note.body) }
}
}
}
}
@if can_annotate {
form method="post"
action={(base) "/decisions/" (decision.decision_id.as_str()) "?action=add_note"}
data-admin-refresh-on-success="#decision-detail" {
(csrf_input(csrf_token))
div class="space-y-3" {
div class="space-y-1" {
(field_label(&note_id, "Add a note"))
textarea id=(note_id) name="body" rows="2" required maxlength=(NOTE_MAX_CHARS)
class=(FIELD_INPUT_CLASS) {}
p class=(FIELD_HELP_CLASS) { "Staff only. Notes cannot be changed and expire after a year." }
}
(form_actions(submit_button("Add note")))
}
}
}
}
}))
}
let notes: Vec<StaffNote> = decision
.notes
.iter()
.map(|note| StaffNote {
note_id: note.note_id.as_str(),
author: user_link(base, users, &note.author_id),
body: &note.body,
created_at: &note.created_at,
})
.collect();
staff_notes_section(
&notes,
can_annotate.then(|| StaffNoteForm {
action: format!(
"{base}/decisions/{}?action=add_note",
decision.decision_id.as_str()
),
refresh_target: "#decision-detail",
field_id: format!("note-{}", decision.decision_id.as_str()),
help: "Staff only. Notes cannot be changed and expire after a year.",
csrf_token,
}),
)
}
pub fn decision_user_ids(decision: &EnforcementDecisionResponse) -> Vec<String> {
@@ -41,6 +41,8 @@ use crate::{
},
resource_link::{ResourceType, resource_link},
section_card::section_card,
staff_notes::{StaffNote, StaffNoteForm, staff_notes_section},
user_display::user_link,
},
layout::admin_layout,
},
@@ -601,13 +603,14 @@ fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup
class={(FORM_CONTROL_CLASS) " h-8 appearance-none px-3 py-1.5 pr-10"} {
option value="" selected disabled { "Choose one" }
option value="removed" { "Content removed" }
option value="warned" { "Warning given" }
option value="restricted" { "Account restricted" }
option value="action_taken" { "Other action taken" }
}
(select_chevron())
}
p class="text-neutral-500 text-xs" {
"What happened to the reported content or account. The reporter is told it. Other action taken, such as a warning, tells them we acted but not how."
"What happened to the reported content or account. The reporter is told it. Warning given and other action taken tell them we acted but not how."
}
}
fieldset class="flex flex-col gap-3" data-show-for-resolution="duplicate" hidden disabled {
@@ -943,6 +946,36 @@ fn snapshot_guild_group(
}
}
fn notes_card(
config: &AdminConfig,
auth: &AuthContext,
report: &ReportEntry,
note_authors: &[AdminUser],
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
let notes: Vec<StaffNote> = report
.notes
.iter()
.map(|note| StaffNote {
note_id: &note.note_id,
author: user_link(base, note_authors, &note.author_id),
body: &note.body,
created_at: &note.created_at,
})
.collect();
staff_notes_section(
&notes,
has_acl(auth, acl::REPORT_RESOLVE).then(|| StaffNoteForm {
action: format!("{base}/reports/{}/notes", report.report_id),
refresh_target: "#report-detail",
field_id: format!("note-{}", report.report_id),
help: "Staff only. Never shown to the reporter or the reported account. Notes cannot be changed and are deleted with the report.",
csrf_token,
}),
)
}
fn legal_hold_card(
config: &AdminConfig,
auth: &AuthContext,
@@ -1040,7 +1073,7 @@ fn delete_report_card(
(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. \
"Deletes the report now, with its message context, profile snapshot, notes, stored evidence and search entry. \
Evidence that another report uses is kept. This cannot be undone."
}
@if held {
@@ -1128,6 +1161,7 @@ pub fn report_detail_page(
auth: &AuthContext,
report: &ReportEntry,
live: &LiveProfile,
note_authors: &[AdminUser],
csrf_token: &str,
is_htmx: bool,
) -> Markup {
@@ -1148,6 +1182,7 @@ pub fn report_detail_page(
(profile_snapshot_section(config, report, live))
(message_context_section(config, report, can_take_action(admin_acls(auth))))
(additional_info_section(report))
(notes_card(config, auth, report, note_authors, csrf_token))
}
div class="space-y-6" {
(status_card(config, report))
@@ -18,7 +18,7 @@ use crate::{
enforcement_fields::{
BasisFields, DecisionFields, FIELD_HELP_CLASS, FIELD_INPUT_CLASS,
FIELD_LABEL_CLASS, basis_fields, deletion_days_field, enforcement_fields,
field_label, internal_note_field, network_block_fields,
field_label, internal_note_field, network_block_fields, required_mark,
},
form::{checkbox, csrf_input, danger_button, form_actions, submit_button},
page_container::card_with_header,
@@ -550,7 +550,7 @@ fn limit_card(base: &str, user: &AdminUser, csrf_token: &str, prefill: &Decision
allow_restricted: true,
}))
fieldset class="space-y-1" {
legend class=(FIELD_LABEL_CLASS) { "Limit" }
legend class=(FIELD_LABEL_CLASS) { "Limit" (required_mark()) }
div class="flex flex-wrap gap-x-4 gap-y-1" {
@for limit in ENFORCEMENT_LIMITS {
@let id = format!("limit-{}", limit.value);
+11
View File
@@ -466,6 +466,16 @@ async fn moderation_tab_without_a_restriction_offers_suspend_and_warn_with_prefi
assert!(suspend.contains(&format!(">{REPORT_ID}</textarea>")));
assert!(suspend.contains(&format!(">{CHANNEL_ID}/{MESSAGE_ID}</textarea>")));
assert!(section(&html, "?action=warn").contains("Send warning"));
let limit = between(&html, "?action=limit_features", "?action=warn");
for expected in [
format!(">{REPORT_ID}</textarea>"),
format!(">{CHANNEL_ID}/{MESSAGE_ID}</textarea>"),
r#"name="source" value="report" required checked"#.to_owned(),
r#"Limit<span class="text-red-500 ml-0.5">*</span></legend>"#.to_owned(),
r#"Internal note<span class="text-red-500 ml-0.5">*</span></label>"#.to_owned(),
] {
assert!(limit.contains(&expected), "missing {expected}\n{limit}");
}
}
fn between<'a>(html: &'a str, start: &str, end: &str) -> &'a str {
@@ -509,6 +519,7 @@ async fn moderation_tab_offers_close_and_limit_features() {
assert!(limit.contains(expected), "missing {expected}\n{limit}");
}
assert!(!limit.contains(r#"<option value="11""#));
assert!(!limit.contains("checked"));
}
#[tokio::test]
+133
View File
@@ -140,6 +140,128 @@ async fn report_legal_hold_form_places_and_clears_the_hold() {
assert_eq!(received.lock().unwrap().len(), 3);
}
#[tokio::test]
async fn report_notes_show_the_author_and_time_and_the_form_adds_one() {
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
let sink = received.clone();
let api = Router::new()
.route(
"/admin/reports/1800000000000000001",
routing::get(|| async { json_response(report_with_note()) }),
)
.route(
"/admin/reports/1800000000000000001/notes",
routing::post(move |Json(body): Json<Value>| {
let sink = sink.clone();
async move {
sink.lock().unwrap().push(body.clone());
json_response(json!({
"note_id": "1950000000000000002",
"author_id": "1500000000000000000",
"body": body["body"],
"created_at": "2026-05-27T08:00:00.000Z"
}))
}
}),
)
.route(
"/admin/users",
routing::get(|| async {
json_response(json!({ "users": [user("1500000000000000000", "AdminUser")] }))
}),
)
.fallback(mock_api);
let app = setup_with_api(api).await;
let (headers, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
let note_start = page
.find(r#"data-note="1950000000000000001""#)
.unwrap_or_else(|| panic!("missing note\n{page}"));
let note = &page[note_start..page[note_start..].find("</li>").unwrap() + note_start];
assert!(
note.contains(r#"href="/users/1500000000000000000""#),
"{note}"
);
assert!(note.contains("AdminUser"), "{note}");
assert!(note.contains("May 26, 2026, 1:30 PM UTC"), "{note}");
assert!(
note.contains("Reporter sent more context by email"),
"{note}"
);
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/notes", &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 {
let (status, headers, text) = post_form_with_headers(
app,
"/reports/1800000000000000001/notes",
&[
("HX-Request", "true"),
("HX-Target", "flash-container"),
("Cookie", &cookie),
],
&body,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "{text}");
headers
.get("X-Fluxer-Admin-Toast")
.and_then(|value| value.to_str().ok())
.unwrap_or_else(|| panic!("missing toast header\n{text}"))
.to_owned()
}
};
let toast = post(format!(
"_csrf={csrf_token}&body=++Waiting+on+the+guild+owner++"
))
.await;
assert!(toast.contains("success"), "{toast}");
assert!(toast.contains("Note added"), "{toast}");
let toast = post(format!("_csrf={csrf_token}&body=+")).await;
assert!(toast.contains("error"), "{toast}");
assert!(toast.contains("Write a note first"), "{toast}");
assert_eq!(
*received.lock().unwrap(),
vec![json!({"body": "Waiting on the guild owner"})]
);
}
#[tokio::test]
async fn report_notes_hide_the_form_without_report_resolve() {
let api = Router::new()
.route(
"/admin/users/@me",
routing::get(|| async {
let mut admin = user("1500000000000000000", "AdminUser");
admin["acls"] = json!(["report:view"]);
json_response(json!({ "user": admin }))
}),
)
.route(
"/admin/reports/1800000000000000001",
routing::get(|| async { json_response(report_with_note()) }),
)
.fallback(mock_api);
let app = setup_with_api(api).await;
let (_, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
assert!(
page.contains("Reporter sent more context by email"),
"{page}"
);
assert!(
page.contains(r#"href="/users/1500000000000000000""#),
"{page}"
);
assert!(
!page.contains(r#"action="/reports/1800000000000000001/notes""#),
"{page}"
);
}
#[tokio::test]
async fn report_delete_form_confirms_sends_the_reason_and_toasts_a_held_report() {
let received = Arc::new(Mutex::new(Vec::<Option<String>>::new()));
@@ -746,6 +868,17 @@ fn searched_report() -> Value {
})
}
fn report_with_note() -> Value {
let mut report = searched_report();
report["notes"] = json!([{
"note_id": "1950000000000000001",
"author_id": "1500000000000000000",
"body": "Reporter sent more context by email",
"created_at": "2026-05-26T13:30:00.000Z"
}]);
report
}
fn report_reasons() -> Value {
json!({
"reasons": [
@@ -186,6 +186,8 @@ async fn report_resolve_sends_notify_reporter_from_the_checkbox() {
assert_eq!(duplicate.body["notify_reporter"], json!(true));
assert!(duplicate.body.get("outcome").is_none());
assert_eq!(duplicate.body["duplicate_of"], json!("1400000000000000001"));
let warned = submit(&uri, "resolution=actioned&outcome=warned").await;
assert_eq!(warned.body["outcome"], json!("warned"));
let other_action = submit(&uri, "resolution=actioned&outcome=action_taken").await;
assert_eq!(other_action.body["outcome"], json!("action_taken"));
}
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DomainRoutedEmailProvider} from '@pkgs/email/src/DomainRoutedEmailProvider';
import type {EmailMessage} from '@pkgs/email/src/EmailProviderTypes';
import {EmailService} from '@pkgs/email/src/EmailService';
import {describe, expect, it, vi} from 'vitest';
function message(to: string): EmailMessage {
return {to, from: {email: '[email protected]', name: 'Example'}, subject: 'Hello', text: 'Body'};
}
describe('DomainRoutedEmailProvider', () => {
it.each([
{to: '[email protected]', route: 'fallback'},
{to: '[email protected]', route: 'fallback'},
{to: '[email protected]', route: 'fallback'},
{to: '[email protected]', route: 'primary'},
{to: '[email protected]', route: 'primary'},
{to: '[email protected]', route: 'primary'},
{to: '"[email protected]"@example.com', route: 'primary'},
])('sends $to through the $route provider', async ({to, route}) => {
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
const fallback = {sendEmail: vi.fn().mockResolvedValue(false)};
const provider = new DomainRoutedEmailProvider(primary, fallback, ['gmail.com', 'googlemail.com']);
const sent = message(to);
const result = await provider.sendEmail(sent);
const [chosen, other] = route === 'fallback' ? [fallback, primary] : [primary, fallback];
expect(chosen.sendEmail).toHaveBeenCalledWith(sent);
expect(other.sendEmail).not.toHaveBeenCalled();
expect(result).toBe(route === 'primary');
});
it('sends every address through the fallback provider when the domains are *', async () => {
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
const fallback = {sendEmail: vi.fn().mockResolvedValue(true)};
const provider = new DomainRoutedEmailProvider(primary, fallback, ['*']);
await provider.sendEmail(message('[email protected]'));
await provider.sendEmail(message('[email protected]'));
expect(fallback.sendEmail).toHaveBeenCalledTimes(2);
expect(primary.sendEmail).not.toHaveBeenCalled();
});
it('never reaches either provider for a bounced address', async () => {
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
const fallback = {sendEmail: vi.fn().mockResolvedValue(true)};
const service = new EmailService(
{
enabled: true,
fromEmail: '[email protected]',
fromName: 'Example',
appBaseUrl: 'https://example.com',
appealsEmail: null,
safetyEmail: null,
supportEmail: null,
productName: 'Example',
},
new DomainRoutedEmailProvider(primary, fallback, ['gmail.com']),
{isEmailBounced: async () => true},
);
expect(await service.sendPasswordResetEmail('[email protected]', 'user', 'token')).toBe(false);
expect(primary.sendEmail).not.toHaveBeenCalled();
expect(fallback.sendEmail).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createLogger} from '@fluxer/logger/src/Logger';
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
const logger = createLogger('@pkgs/email/src/DomainRoutedEmailProvider');
export class DomainRoutedEmailProvider implements IEmailProvider {
private readonly primary: IEmailProvider;
private readonly fallback: IEmailProvider;
private readonly fallbackDomains: ReadonlySet<string>;
constructor(primary: IEmailProvider, fallback: IEmailProvider, fallbackDomains: ReadonlyArray<string>) {
this.primary = primary;
this.fallback = fallback;
this.fallbackDomains = new Set(fallbackDomains);
}
async sendEmail(message: EmailMessage): Promise<boolean> {
const domain = message.to
.slice(message.to.lastIndexOf('@') + 1)
.trim()
.toLowerCase();
const useFallback = this.fallbackDomains.has('*') || this.fallbackDomains.has(domain);
logger.debug({domain, provider: useFallback ? 'fallback' : 'primary'}, 'Routing email');
return (useFallback ? this.fallback : this.primary).sendEmail(message);
}
}
@@ -25,4 +25,33 @@ describe('SmtpEmailProvider', () => {
new SmtpEmailProvider({host: 'smtp.example.com', port, username: 'user', password: 'pass', tlsMode});
expect(createTransport).toHaveBeenCalledWith(expect.objectContaining(expected));
});
const message = {
to: '[email protected]',
from: {email: '[email protected]', name: 'Example'},
subject: 'Hi',
text: 'Body',
};
it('sends the configured headers verbatim', async () => {
const provider = new SmtpEmailProvider({
host: 'smtp.example.com',
port: 587,
username: 'user',
password: 'pass',
headers: {'X-Tracking': '{"enable":0}'},
});
await provider.sendEmail(message);
const {sendMail} = createTransport.mock.results[0].value;
expect(sendMail).toHaveBeenCalledWith(
expect.objectContaining({headers: {'X-Tracking': {prepared: true, value: '{"enable":0}'}}}),
);
});
it('sends no extra headers when none are configured', async () => {
const provider = new SmtpEmailProvider({host: 'smtp.example.com', port: 587, username: 'user', password: 'pass'});
await provider.sendEmail(message);
const {sendMail} = createTransport.mock.results[0].value;
expect(sendMail.mock.calls[0][0]).not.toHaveProperty('headers');
});
});
@@ -13,6 +13,7 @@ interface SmtpEmailConfig {
username: string;
password: string;
tlsMode?: SmtpTlsMode | null;
headers?: Record<string, string>;
connectionTimeoutMs?: number;
greetingTimeoutMs?: number;
socketTimeoutMs?: number;
@@ -23,11 +24,17 @@ export class SmtpEmailProvider implements IEmailProvider {
private readonly host: string;
private readonly port: number;
private readonly tlsMode: SmtpTlsMode;
private readonly headers: Record<string, {prepared: true; value: string}> | undefined;
constructor(config: SmtpEmailConfig) {
this.host = config.host;
this.port = config.port;
this.tlsMode = resolveSmtpTlsMode(config.tlsMode, config.port);
this.headers = config.headers
? Object.fromEntries(
Object.entries(config.headers).map(([name, value]) => [name, {prepared: true as const, value}]),
)
: undefined;
this.transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
@@ -55,6 +62,7 @@ export class SmtpEmailProvider implements IEmailProvider {
to: message.to,
from: `${message.from.name} <${message.from.email}>`,
...(message.replyTo ? {replyTo: message.replyTo} : {}),
...(this.headers ? {headers: this.headers} : {}),
subject: message.subject,
text: message.text,
});
+19 -8
View File
@@ -39,6 +39,18 @@ function resolveEmailAppBaseUrl(master: MasterConfig): string {
}
}
function toSmtpConfig(
smtp: NonNullable<MasterConfig['integrations']['email']['smtp']>,
): NonNullable<APIConfig['email']['smtp']> {
return {
host: smtp.host,
port: smtp.port,
username: smtp.username,
password: smtp.password,
tlsMode: smtp.tls_mode ?? smtpTlsModeFromSecure(smtp.secure),
};
}
function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
@@ -291,14 +303,14 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
fromName: master.integrations.email.from_name,
replyToEmail: master.integrations.email.reply_to_email,
appBaseUrl: resolveEmailAppBaseUrl(master),
smtp: master.integrations.email.smtp
smtp: master.integrations.email.smtp ? toSmtpConfig(master.integrations.email.smtp) : undefined,
fallback: master.integrations.email.fallback
? {
host: master.integrations.email.smtp.host,
port: master.integrations.email.smtp.port,
username: master.integrations.email.smtp.username,
password: master.integrations.email.smtp.password,
tlsMode:
master.integrations.email.smtp.tls_mode ?? smtpTlsModeFromSecure(master.integrations.email.smtp.secure),
domains: master.integrations.email.fallback.domains,
smtp: {
...toSmtpConfig(master.integrations.email.fallback.smtp),
headers: master.integrations.email.fallback.smtp.headers,
},
}
: undefined,
},
@@ -449,7 +461,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
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: {
+8
View File
@@ -245,7 +245,9 @@ import {
type DSAReportTicketRow,
GUILD_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
type GuildReportSubmissionByReporterRow,
IAR_REPORT_NOTE_COLUMNS,
IAR_SUBMISSION_COLUMNS,
type IARReportNoteRow,
type IARSubmissionRow,
MESSAGE_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
type MessageReportSubmissionByReporterRow,
@@ -941,6 +943,12 @@ export const IARSubmissions = defineTable<IARSubmissionRow, 'report_id'>({
columns: IAR_SUBMISSION_COLUMNS,
primaryKey: ['report_id'],
});
export const IARReportNotes = defineTable<IARReportNoteRow, 'report_id' | 'note_id', 'report_id'>({
name: 'iar_report_notes',
columns: IAR_REPORT_NOTE_COLUMNS,
primaryKey: ['report_id', 'note_id'],
partitionKey: ['report_id'],
});
export const MessageReportSubmissionsByReporter = defineTable<
MessageReportSubmissionByReporterRow,
'reporter_id' | 'channel_id' | 'message_id',
@@ -42,6 +42,7 @@ export const AdminAuditReadActions = {
LIST_GUILD_THREADS: 'list_guild_threads',
LIST_NETWORK_BLOCK_CANDIDATES: 'list_network_block_candidates',
LIST_PENDING_REGISTRATIONS: 'list_pending_registrations',
LIST_REPORT_NOTES: 'list_report_notes',
LIST_REPORT_REASONS: 'list_report_reasons',
LIST_USER_APPLICATIONS: 'list_user_applications',
LIST_USER_CHANGE_LOG: 'list_user_change_log',
+2
View File
@@ -38,6 +38,7 @@ import {
getEnforcementDecisionRepository,
getGuildDiscoveryRepository,
getKVAccountDeletionQueue,
getReportRepository,
} from '@app/api/middleware/ServiceSingletons';
import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository';
import type {ReportService} from '@app/api/report/ReportService';
@@ -120,6 +121,7 @@ export class AdminService {
this.reportServiceAggregate = new AdminReportService({
apiContext: this.apiContext,
reportService: this.reportService,
reportRepository: getReportRepository(),
guildRepository: this.guildRepository,
channelRepository: this.channelRepository,
storageService: this.storageService,
@@ -18,6 +18,11 @@ import {
ReportLegalHoldRequest,
ReportLegalHoldResponse,
} from '@fluxer/schema/src/domains/admin/AdminReportLegalHoldSchemas';
import {
ReportNoteListResponse,
ReportNoteRequest,
ReportNoteResponse,
} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
import {
AdminReportListResponse,
AdminReportReasonsResponse,
@@ -176,7 +181,7 @@ export function ReportAdminController(app: HonoApp) {
operationId: 'get_admin_report',
summary: 'Get report',
description:
'Retrieves detailed information about a specific report including content, reporter, reason, and the message context and profile snapshot captured when it was filed. Requires REPORT_VIEW permission.',
'Retrieves detailed information about a specific report including content, reporter, reason, the message context and profile snapshot captured when it was filed, and its internal notes. Requires REPORT_VIEW permission.',
responseSchema: ReportAdminResponseSchema,
statusCode: 200,
security: 'adminApiKey',
@@ -234,6 +239,64 @@ export function ReportAdminController(app: HonoApp) {
);
},
);
app.get(
'/admin/reports/:report_id/notes',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.REPORT_VIEW),
Validator('param', ReportIdParam),
OpenAPI({
operationId: 'list_admin_report_notes',
summary: 'List report notes',
description:
'Lists the internal notes on a report, oldest first. Notes are staff only and are deleted with the report. Requires REPORT_VIEW permission.',
responseSchema: ReportNoteListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const {report_id} = ctx.req.valid('param');
const result = await ctx.get('adminService').reportServiceAggregate.listNotes(createReportID(report_id));
await recordAdminRead(ctx, {
targetType: 'report',
targetId: report_id,
action: AdminAuditReadActions.LIST_REPORT_NOTES,
metadata: {result_count: result.notes.length},
});
return ctx.json(result);
},
);
app.post(
'/admin/reports/:report_id/notes',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.REPORT_RESOLVE),
Validator('param', ReportIdParam),
Validator('json', ReportNoteRequest),
OpenAPI({
operationId: 'annotate_admin_report',
summary: 'Add a note to a report',
description:
'Appends an internal note to a report. Notes are staff only, never change and are deleted with the report. Creates audit log entry. Requires REPORT_RESOLVE permission.',
responseSchema: ReportNoteResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const {report_id} = ctx.req.valid('param');
const reportId = createReportID(report_id);
const note = await ctx
.get('adminService')
.reportServiceAggregate.addNote(reportId, ctx.req.valid('json').body, ctx.get('adminUserId'));
await recordAdminWrite(ctx, {
targetType: 'report',
targetId: report_id,
action: 'annotate_report',
metadata: {report_id: reportId.toString(), note_id: note.note_id},
});
return ctx.json(note);
},
);
app.post(
'/admin/reports/:report_id/legal-hold',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
@@ -294,7 +357,7 @@ export function ReportAdminController(app: HonoApp) {
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.',
'Deletes a report now: its record with the captured message context and profile snapshot, its notes, 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',
@@ -23,6 +23,7 @@ import {
} from '@app/api/channel/services/message/MessageResponseDataService';
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
import type {IARReportNoteRow} from '@app/api/database/types/ReportTypes';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
@@ -32,6 +33,7 @@ import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {describeReportFlowAnswers} from '@app/api/report/flows/ReportFlowRegistry';
import {findReportReason, listReportReasons} from '@app/api/report/flows/ReportReasonCatalog';
import type {IARMessageContext, IARSubmission} from '@app/api/report/ReportModels';
import type {ReportRepository} from '@app/api/report/ReportRepository';
import type {ReportService} from '@app/api/report/ReportService';
import {getReportSearchService} from '@app/api/SearchFactory';
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
@@ -44,6 +46,7 @@ import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/F
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
import type {ReportSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {ReportNoteListResponse, ReportNoteResponse} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
import type {
AdminReportReasonsResponse,
ReportItemOutcome,
@@ -58,6 +61,7 @@ import {seconds} from 'itty-time';
interface AdminReportServiceDeps {
apiContext: ApiContext;
reportService: ReportService;
reportRepository: ReportRepository;
guildRepository: GuildRepository;
channelRepository: ChannelRepository;
storageService: IStorageService;
@@ -136,6 +140,15 @@ function mapMissingAttachmentToResponse(attachment: MessageAttachment) {
};
}
function toReportNoteResponse(note: IARReportNoteRow): ReportNoteResponse {
return {
note_id: note.note_id.toString(),
author_id: note.author_id.toString(),
body: note.body,
created_at: note.created_at.toISOString(),
};
}
function mapReportReasonFields(report: IARSubmission) {
const reason = report.reason ? findReportReason(report.reason) : null;
const flow =
@@ -197,7 +210,30 @@ export class AdminReportService {
const report = await reportService.getReport(reportId);
const requestCache = createRequestCache();
const reportLookupCache = createReportLookupCache();
return this.mapReportToResponse(report, true, requestCache, acls, reportLookupCache);
const [response, notes] = await Promise.all([
this.mapReportToResponse(report, true, requestCache, acls, reportLookupCache),
this.deps.reportRepository.listNotes(reportId),
]);
return {...response, notes: notes.map(toReportNoteResponse)};
}
async listNotes(reportId: ReportID): Promise<ReportNoteListResponse> {
await this.deps.reportService.getReport(reportId);
const notes = await this.deps.reportRepository.listNotes(reportId);
return {notes: notes.map(toReportNoteResponse)};
}
async addNote(reportId: ReportID, body: string, authorId: UserID): Promise<ReportNoteResponse> {
await this.deps.reportService.getReport(reportId);
const note: IARReportNoteRow = {
report_id: reportId,
note_id: await this.deps.apiContext.services.snowflake.generate(),
author_id: authorId,
body,
created_at: new Date(),
};
await this.deps.reportRepository.addNote(note);
return toReportNoteResponse(note);
}
async resolveReport(
@@ -33,6 +33,8 @@ interface ReporterNoticeResult {
skipped: ReporterNoticeSkipReason | null;
}
type ReporterToldOutcome = Exclude<ReportItemOutcome, 'warned'>;
function skippedNotice(reason: ReporterNoticeSkipReason): ReporterNoticeResult {
return {dmSent: false, emailSent: false, dsaEmailSent: false, skipped: reason};
}
@@ -59,17 +61,18 @@ export class ReporterResolutionNotifier {
outcome: ReportItemOutcome,
): Promise<ReporterNoticeResult> {
const comment = publicComment ?? '';
const told = outcome === 'warned' ? 'action_taken' : outcome;
if (report.reporterId) {
return this.notifyAccountReporter(report, report.reporterId, comment, outcome);
return this.notifyAccountReporter(report, report.reporterId, comment, told);
}
return this.notifyDsaReporter(report, comment, outcome);
return this.notifyDsaReporter(report, comment, told);
}
private async notifyAccountReporter(
report: IARSubmission,
reporterId: UserID,
comment: string,
outcome: ReportItemOutcome,
outcome: ReporterToldOutcome,
): Promise<ReporterNoticeResult> {
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
const reportId = report.reportId.toString();
@@ -115,7 +118,7 @@ export class ReporterResolutionNotifier {
private async notifyDsaReporter(
report: IARSubmission,
comment: string,
outcome: ReportItemOutcome,
outcome: ReporterToldOutcome,
): Promise<ReporterNoticeResult> {
const email = report.reporterEmail;
if (!email) {
@@ -134,7 +137,7 @@ export class ReporterResolutionNotifier {
reporter: User,
reportId: string,
comment: string,
outcome: ReportItemOutcome,
outcome: ReporterToldOutcome,
): Promise<boolean> {
const systemDm = this.deps.systemDm;
if (!systemDm) {
@@ -234,6 +234,25 @@ describe('DELETE /admin/reports/:report_id', () => {
});
});
test('deletes the notes with the report and keeps the notes of other reports', async () => {
const deleted = await seedMessageReport(newEvidence());
const other = await seedMessageReport(newEvidence());
for (const seeded of [deleted, other]) {
await repository.addNote({
report_id: seeded.reportId,
note_id: nextId(),
author_id: BigInt(admin.userId),
body: 'Staff context',
created_at: new Date(),
});
}
await deleteRequest(admin, deleted.reportId).expect(HTTP_STATUS.NO_CONTENT).execute();
expect(await repository.listNotes(deleted.reportId)).toEqual([]);
expect(await repository.listNotes(other.reportId)).toHaveLength(1);
});
test('keeps an evidence copy another report still uses', async () => {
const shared = newEvidence();
const deleted = await seedMessageReport(shared);
@@ -3,6 +3,7 @@
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createReportID, type ReportID} from '@app/api/BrandedTypes';
import {IAR_SUBMISSION_COLUMNS, type IARSubmissionRow} from '@app/api/database/types/ReportTypes';
import {EnforcementDecisionRepository} from '@app/api/enforcement/EnforcementDecisionRepository';
import {ReportRepository} from '@app/api/report/ReportRepository';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {MockStorageService} from '@app/api/test/mocks/MockStorageService';
@@ -47,6 +48,7 @@ describe('report legal hold', () => {
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
report_id: sequence,
reported_at: new Date(Date.now() - ageDays * ms('1 day')),
resolved_at: new Date(Date.now() - ageDays * ms('1 day')),
status: 1,
report_type: 1,
category: 'harassment',
@@ -69,8 +71,13 @@ describe('report legal hold', () => {
async function runRetention() {
return processReportRetention(
{reportRepository: repository, storageService: new MockStorageService(), reportSearchService: null},
{now: new Date(), dryRun: false, policy: {retentionDays: 365, resolvedRetentionDays: null}},
{
reportRepository: repository,
storageService: new MockStorageService(),
reportSearchService: null,
decisionRepository: new EnforcementDecisionRepository(),
},
{now: new Date(), dryRun: false, policy: {retentionDays: 365}},
);
}
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditLog} from '@app/api/admin/AdminRepository';
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createReportID, type ReportID} from '@app/api/BrandedTypes';
import {IAR_SUBMISSION_COLUMNS, type IARSubmissionRow} from '@app/api/database/types/ReportTypes';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {ReportRepository} from '@app/api/report/ReportRepository';
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 type {ReportNoteListResponse, ReportNoteResponse} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface ValidationErrorResponse {
code: string;
errors?: Array<{path: string; message: string}>;
}
const AUDIT_REASON = 'Ticket 314';
let sequence = 1_496_000_000_000_000_000n;
describe('report notes', () => {
let harness: ApiTestHarness;
let admin: TestAccount;
let repository: ReportRepository;
beforeEach(async () => {
harness = await createApiTestHarness();
admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
AdminACLs.REPORT_VIEW,
AdminACLs.REPORT_RESOLVE,
]);
repository = new ReportRepository();
});
afterEach(async () => {
await harness?.shutdown();
});
async function seedReport(): Promise<ReportID> {
sequence += 1n;
await repository.createReport({
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
report_id: sequence,
reported_at: new Date(),
status: 0,
report_type: 1,
category: 'harassment',
} as IARSubmissionRow);
return createReportID(sequence);
}
function addNote(account: TestAccount, reportId: ReportID | string, body: unknown) {
return createBuilder<ReportNoteResponse & ValidationErrorResponse>(harness, account.token)
.post(`/admin/reports/${reportId}/notes`)
.header('X-Audit-Log-Reason', AUDIT_REASON)
.body({body});
}
function listNotes(account: TestAccount, reportId: ReportID | string) {
return createBuilder<ReportNoteListResponse>(harness, account.token).get(`/admin/reports/${reportId}/notes`);
}
async function auditEntries(action: string): Promise<Array<AdminAuditLog>> {
const logs = await getAdminRepository().listAllAuditLogsPaginated(10000);
return logs.filter((log) => log.action === action);
}
test('adds notes, lists them oldest first and returns them with the report', async () => {
const reportId = await seedReport();
const first = await addNote(admin, reportId, ' Reporter sent more context by email ')
.expect(HTTP_STATUS.OK)
.execute();
const second = await addNote(admin, reportId, 'Waiting on the guild owner').expect(HTTP_STATUS.OK).execute();
expect(first).toEqual({
note_id: expect.any(String),
author_id: admin.userId,
body: 'Reporter sent more context by email',
created_at: expect.any(String),
});
expect(await listNotes(admin, reportId).expect(HTTP_STATUS.OK).execute()).toEqual({notes: [first, second]});
const detail = await createBuilder<{notes?: Array<ReportNoteResponse>}>(harness, admin.token)
.get(`/admin/reports/${reportId}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(detail.notes).toEqual([first, second]);
});
test('records the note in the audit log without its body', async () => {
const reportId = await seedReport();
const note = await addNote(admin, reportId, 'Private staff context').expect(HTTP_STATUS.OK).execute();
const [entry, ...rest] = await auditEntries('annotate_report');
expect(rest).toEqual([]);
expect(entry?.adminUserId.toString()).toBe(admin.userId);
expect(entry?.targetType).toBe('report');
expect(entry?.targetId.toString()).toBe(reportId.toString());
expect(entry?.auditLogReason).toBe(AUDIT_REASON);
expect(Object.fromEntries(entry?.metadata ?? [])).toEqual({
report_id: reportId.toString(),
note_id: note.note_id,
});
});
test.each([
['empty', ''],
['blank', ' '],
['too long', 'x'.repeat(2001)],
['not a string', 42],
])('refuses a %s body and stores nothing', async (_, body) => {
const reportId = await seedReport();
const response = await addNote(admin, reportId, body)
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
expect(response.errors?.[0]?.path).toBe('body');
expect(await repository.listNotes(reportId)).toEqual([]);
expect(await auditEntries('annotate_report')).toEqual([]);
});
test('accepts a body of 2000 characters', async () => {
const reportId = await seedReport();
const note = await addNote(admin, reportId, 'x'.repeat(2000)).expect(HTTP_STATUS.OK).execute();
expect(note.body).toHaveLength(2000);
});
test('an unknown report gives UNKNOWN_REPORT and stores nothing', async () => {
sequence += 1n;
const missing = createReportID(sequence);
await addNote(admin, missing, 'Note').expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_REPORT').execute();
await listNotes(admin, missing).expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_REPORT').execute();
expect(await repository.listNotes(missing)).toEqual([]);
expect(await auditEntries('annotate_report')).toEqual([]);
});
test('adding a note needs REPORT_RESOLVE', async () => {
const reportId = await seedReport();
const viewer = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
AdminACLs.REPORT_VIEW,
]);
await addNote(viewer, reportId, 'Note').expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL').execute();
expect(await repository.listNotes(reportId)).toEqual([]);
expect(await auditEntries('annotate_report')).toEqual([]);
});
test('listing notes needs REPORT_VIEW', async () => {
const reportId = await seedReport();
await addNote(admin, reportId, 'Note').expect(HTTP_STATUS.OK).execute();
const resolver = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
AdminACLs.REPORT_RESOLVE,
]);
await listNotes(resolver, reportId).expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL').execute();
});
});
@@ -333,7 +333,7 @@ describe('Admin staff notifications', () => {
expect(dm?.content).toContain('We found that what you reported broke our rules, and we took action.');
expect(dm?.content).not.toMatch(/warn|restricted|suspend/i);
const metadata = await auditMetadata('resolve_report', reportId);
expect(metadata?.get('outcome')).toBe('action_taken');
expect(metadata?.get('outcome')).toBe('warned');
expect(metadata?.get('reporter_dm_sent')).toBe('true');
});
@@ -347,6 +347,18 @@ describe('Admin staff notifications', () => {
]);
});
test('a DSA notice resolved with a warning emails other action taken', async () => {
const {email, reportId} = await fileDsaGuildNotice();
await clearTestEmails(harness);
await resolve(reportId, {outcome: 'warned'});
const sent = await listTestEmails(harness, {recipient: email});
expect(sent.map((entry) => [entry.type, entry.metadata.outcome])).toEqual([
['dsa_report_resolved', 'action_taken'],
]);
const metadata = await auditMetadata('resolve_report', reportId);
expect(metadata?.get('outcome')).toBe('warned');
});
test('a DSA notice resolved with notify_reporter false gets the decision email without the comment', async () => {
const {email, reportId} = await fileDsaGuildNotice();
await clearTestEmails(harness);
@@ -9,6 +9,7 @@ import {createReportID} from '@app/api/BrandedTypes';
import {getReportRepository} from '@app/api/middleware/ServiceSingletons';
import {listReportReasons} from '@app/api/report/flows/ReportReasonCatalog';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {expect} from 'vitest';
interface FiledUserReport {
reportId: string;
@@ -321,6 +322,42 @@ export const ReportAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
},
}),
),
{
method: 'GET',
route: '/admin/reports/:report_id/notes',
async prepare(context) {
const {reportId} = await fileUserReport(context);
await createBuilder(context.harness, context.admin.token)
.post(`/admin/reports/${reportId}/notes`)
.body({body: 'Coverage note'})
.execute();
return {
request: {path: `/admin/reports/${reportId}/notes`},
expected: {
action: 'list_report_notes',
targetType: 'report',
targetId: reportId,
metadata: {result_count: '1'},
},
};
},
},
{
method: 'POST',
route: '/admin/reports/:report_id/notes',
async prepare(context) {
const {reportId} = await fileUserReport(context);
return {
request: {path: `/admin/reports/${reportId}/notes`, body: {body: 'Coverage note'}},
expected: {
action: 'annotate_report',
targetType: 'report',
targetId: reportId,
metadata: {report_id: reportId, note_id: expect.any(String)},
},
};
},
},
{
method: 'POST',
route: '/admin/reports/:report_id/legal-hold',
+12 -7
View File
@@ -19,6 +19,14 @@ export interface TrustedCallerConfig {
buckets: Array<string>;
}
interface SmtpConfig {
host: string;
port: number;
username: string;
password: string;
tlsMode: SmtpTlsMode | null;
}
interface APICachePurgeTarget {
endpoint: string;
token: string;
@@ -162,12 +170,10 @@ export interface APIConfig {
fromName: string;
replyToEmail: string;
appBaseUrl: string;
smtp?: {
host: string;
port: number;
username: string;
password: string;
tlsMode: SmtpTlsMode | null;
smtp?: SmtpConfig;
fallback?: {
domains: Array<string>;
smtp: SmtpConfig & {headers?: Record<string, string>};
};
};
blocklistFeeds: {
@@ -308,7 +314,6 @@ export interface APIConfig {
deletionGracePeriodHours: number;
reportRetention: {
days: number;
resolvedDays: number | null;
dryRun: boolean;
};
inactivityDeletionThresholdDays?: number;
@@ -8,6 +8,7 @@ import {
DSAReportEmailVerifications,
DSAReportTickets,
GuildReportSubmissionsByReporter,
IARReportNotes,
IARSubmissions,
MessageReportSubmissionsByReporter,
UserReportSubmissionsByReporter,
@@ -52,6 +53,7 @@ const IAR_MESSAGE_CONTEXT_FIELDS = {
const REPORT_TABLES = [
IARSubmissions,
IARReportNotes,
MessageReportSubmissionsByReporter,
UserReportSubmissionsByReporter,
GuildReportSubmissionsByReporter,
@@ -89,6 +89,14 @@ export interface IARSubmissionRow {
reporter_outcome?: string | null;
}
export interface IARReportNoteRow {
report_id: bigint;
note_id: bigint;
author_id: bigint;
body: string;
created_at: Date;
}
export interface DSAReportEmailVerificationRow {
email_lower: string;
code_hash: string;
@@ -186,6 +194,13 @@ export const IAR_SUBMISSION_COLUMNS = [
'legal_hold_reason',
'reporter_outcome',
] as const satisfies ReadonlyArray<keyof IARSubmissionRow>;
export const IAR_REPORT_NOTE_COLUMNS = [
'report_id',
'note_id',
'author_id',
'body',
'created_at',
] as const satisfies ReadonlyArray<keyof IARReportNoteRow>;
export const DSA_REPORT_EMAIL_VERIFICATION_COLUMNS = [
'email_lower',
'code_hash',
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APIConfig} from '@app/api/config/APIConfig';
import {createEmailProvider} from '@app/api/email/EmailProviderFactory';
import {DomainRoutedEmailProvider} from '@pkgs/email/src/DomainRoutedEmailProvider';
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import nodemailer, {type Transporter} from 'nodemailer';
import {describe, expect, it, vi} from 'vitest';
const smtp = {host: 'smtp.example.com', port: 587, username: 'user', password: 'pass', tlsMode: null};
function emailConfig(overrides: Partial<APIConfig['email']> = {}): APIConfig['email'] {
return {
enabled: true,
provider: 'smtp',
fromEmail: '[email protected]',
fromName: 'Example',
replyToEmail: '',
appBaseUrl: 'https://example.com',
smtp,
...overrides,
};
}
describe('createEmailProvider', () => {
it('returns the plain SMTP provider without a fallback', () => {
expect(createEmailProvider(emailConfig())).toBeInstanceOf(SmtpEmailProvider);
});
it('routes by domain when a fallback is set', () => {
const provider = createEmailProvider(
emailConfig({fallback: {domains: ['gmail.com'], smtp: {...smtp, host: 'smtp.sendgrid.net'}}}),
);
expect(provider).toBeInstanceOf(DomainRoutedEmailProvider);
});
it('adds the fallback headers only to mail through the fallback', async () => {
const sendMail = vi.fn();
vi.spyOn(nodemailer, 'createTransport').mockReturnValue({sendMail} as unknown as Transporter);
const headers = {'X-Tracking': '{"enable":0}'};
const provider = createEmailProvider(emailConfig({fallback: {domains: ['gmail.com'], smtp: {...smtp, headers}}}));
const from = {email: '[email protected]', name: 'Example'};
await provider?.sendEmail({to: '[email protected]', from, subject: 'Hi', text: 'Body'});
await provider?.sendEmail({to: '[email protected]', from, subject: 'Hi', text: 'Body'});
expect(sendMail.mock.calls[0][0].headers).toEqual({'X-Tracking': {prepared: true, value: '{"enable":0}'}});
expect(sendMail.mock.calls[1][0]).not.toHaveProperty('headers');
});
it.each([{enabled: false}, {provider: 'none' as const}, {smtp: undefined}])(
'returns no provider for %j even with a fallback',
(overrides) => {
const fallback = {domains: ['gmail.com'], smtp};
expect(createEmailProvider(emailConfig({fallback, ...overrides}))).toBeNull();
},
);
});
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APIConfig} from '@app/api/config/APIConfig';
import {DomainRoutedEmailProvider} from '@pkgs/email/src/DomainRoutedEmailProvider';
import type {IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
@@ -9,16 +10,14 @@ export function createEmailProvider(emailConfig: APIConfig['email']): IEmailProv
return null;
}
switch (emailConfig.provider) {
case 'smtp':
return emailConfig.smtp
? new SmtpEmailProvider({
host: emailConfig.smtp.host,
port: emailConfig.smtp.port,
username: emailConfig.smtp.username,
password: emailConfig.smtp.password,
tlsMode: emailConfig.smtp.tlsMode,
})
: null;
case 'smtp': {
if (!emailConfig.smtp) return null;
const primary = new SmtpEmailProvider(emailConfig.smtp);
const {fallback} = emailConfig;
return fallback
? new DomainRoutedEmailProvider(primary, new SmtpEmailProvider(fallback.smtp), fallback.domains)
: primary;
}
default:
return null;
}
@@ -40,6 +40,16 @@ const LIST_NOTES_QUERY = EnforcementDecisionNotes.select({
orderBy: {col: 'note_id', direction: 'ASC'},
});
type DecisionReportLink = Pick<EnforcementDecisionRow, 'decision_id' | 'report_ids' | 'created_at'>;
function listReportLinksQuery(limit: number, paged: boolean) {
return EnforcementDecisions.select({
columns: ['decision_id', 'report_ids', 'created_at'],
...(paged ? {where: EnforcementDecisions.where.tokenGt('decision_id', 'after')} : {}),
limit,
});
}
const REVIEW_QUEUE_BUCKET = 0;
const LIST_REVIEW_QUEUE_QUERY = EnforcementReviewQueue.select({
columns: ['decision_id'],
@@ -162,6 +172,12 @@ export class EnforcementDecisionRepository {
return this.getMany(keys.map((key) => key.decision_id));
}
async listReportLinks(limit: number, after?: bigint): Promise<Array<DecisionReportLink>> {
return fetchMany<DecisionReportLink>(
listReportLinksQuery(limit, after !== undefined).bind(after !== undefined ? {after} : {}),
);
}
async end(decisionId: bigint, end: EnforcementDecisionEnd): Promise<void> {
await upsertOne(EnforcementDecisions.patchByPk({decision_id: decisionId}, endPatch(end)));
}
@@ -239,7 +239,7 @@ function reportOutcome(
(removeEvidence && evidence.some((message) => message.message_id === messageId));
if (removed) return 'removed';
}
if (kind === 'warning') return 'action_taken';
if (kind === 'warning') return 'warned';
return RESTRICTING_KINDS.has(kind) ? 'restricted' : null;
}
@@ -57,6 +57,7 @@ import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {resolveSmtpTlsMode, type SmtpTlsMode, smtpTlsModeFromSecure} from '@fluxer/constants/src/SmtpConstants';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import {normalizeThemeColor} from '@fluxer/instance_bootstrap/src/ThemeColor';
import {
InstanceConfigResponse,
InstanceConfigUpdateRequest,
@@ -414,14 +415,6 @@ function normalizeOptionalPublicString(value: string | null | undefined, fallbac
return value === undefined ? fallback : normalizeOptionalString(value);
}
function normalizeThemeColor(value: unknown): string | null {
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(normalizeOptionalString(value) ?? '');
if (match === null) return null;
const digits = match[1].toLowerCase();
const color = `#${digits.length === 3 ? digits.replace(/./g, '$&$&') : digits}`;
return color === '#000000' ? null : color;
}
function getDefaultPremiumProductName(): string {
return Config.instance.selfHosted ? 'Premium' : 'Plutonium';
}
+32 -3
View File
@@ -79,13 +79,12 @@ async function storedObjectKeys(storageService: IStorageService, report: IARSubm
return keys;
}
export async function deleteReportWithEvidence(
deps: ReportDeletionDeps,
async function deleteReportEvidence(
storageService: IStorageService,
report: IARSubmission,
ledger: ReportEvidenceLedger,
options: {now: Date; dryRun: boolean},
): Promise<ReportDeletionResult> {
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)) {
@@ -105,6 +104,17 @@ export async function deleteReportWithEvidence(
ledger.deletedKeys.add(key);
result.objectsDeleted++;
}
return result;
}
export async function deleteReportWithEvidence(
deps: ReportDeletionDeps,
report: IARSubmission,
ledger: ReportEvidenceLedger,
options: {now: Date; dryRun: boolean},
): Promise<ReportDeletionResult> {
const {reportRepository, storageService, reportSearchService} = deps;
const result = await deleteReportEvidence(storageService, report, ledger, options);
if (!options.dryRun) {
await reportSearchService?.deleteReport(report.reportId);
await reportRepository.deleteReport(report.reportId);
@@ -112,6 +122,25 @@ export async function deleteReportWithEvidence(
return result;
}
export async function clearCopiedContextWithEvidence(
deps: ReportDeletionDeps,
report: IARSubmission,
ledger: ReportEvidenceLedger,
options: {now: Date; dryRun: boolean},
): Promise<ReportDeletionResult> {
const {reportRepository, storageService, reportSearchService} = deps;
const result = await deleteReportEvidence(storageService, report, ledger, options);
if (!options.dryRun && (await reportRepository.clearCopiedContext(report.reportId, report.reportType))) {
await reportSearchService?.updateReport({
...report,
messageContext: null,
reportedProfileSnapshot: null,
additionalInfo: null,
});
}
return result;
}
async function loadDeletableReport(
reportRepository: ReportRepository,
reportId: ReportID,
+36 -1
View File
@@ -10,12 +10,19 @@ import {
createUserID,
createWebhookID,
} from '@app/api/BrandedTypes';
import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {
deleteOneOrMany,
executeConditional,
fetchMany,
fetchOne,
upsertOne,
} from '@app/api/database/CassandraQueryExecution';
import {Db} from '@app/api/database/CassandraTypes';
import type {
DSAReportEmailVerificationRow,
DSAReportTicketRow,
GuildReportSubmissionByReporterRow,
IARReportNoteRow,
MessageReportSubmissionByReporterRow,
UserReportSubmissionByReporterRow,
} from '@app/api/database/types/ReportTypes';
@@ -32,6 +39,7 @@ import {
DSAReportEmailVerifications,
DSAReportTickets,
GuildReportSubmissionsByReporter,
IARReportNotes,
IARSubmissions,
MessageReportSubmissionsByReporter,
UserReportSubmissionsByReporter,
@@ -45,6 +53,10 @@ const GET_REPORT_QUERY = IARSubmissions.select({
where: IARSubmissions.where.eq('report_id'),
limit: 1,
});
const LIST_NOTES_QUERY = IARReportNotes.select({
where: IARReportNotes.where.eq('report_id'),
orderBy: {col: 'note_id', direction: 'ASC'},
});
const createFetchAllReportsPaginatedQuery = (limit: number) =>
IARSubmissions.select({
where: IARSubmissions.where.tokenGt('report_id', 'last_report_id'),
@@ -186,6 +198,20 @@ export class ReportRepository {
return {...report, legalHoldUntil, legalHoldReason};
}
async clearCopiedContext(reportId: ReportID, reportType: number): Promise<boolean> {
return executeConditional(
IARSubmissions.conditionalPatchByPk(
{report_id: reportId},
{
message_context: Db.set(null),
reported_profile_snapshot: Db.set(null),
additional_info: Db.set(null),
},
{report_type: reportType},
),
);
}
async clearReporterEmail(reportId: ReportID, reporterId: UserID): Promise<boolean> {
return executeConditional(
IARSubmissions.conditionalPatchByPk(
@@ -202,11 +228,20 @@ export class ReportRepository {
return;
}
await this.releaseReporterReservation(report);
await deleteOneOrMany(IARReportNotes.deletePartition({report_id: reportId}));
await executeConditional(
IARSubmissions.conditionalDeleteByPk({report_id: reportId}, {report_type: report.reportType}),
);
}
async addNote(note: IARReportNoteRow): Promise<void> {
await upsertOne(IARReportNotes.insert(note));
}
async listNotes(reportId: ReportID): Promise<Array<IARReportNoteRow>> {
return fetchMany<IARReportNoteRow>(LIST_NOTES_QUERY.bind({report_id: reportId}));
}
private async releaseReporterReservation(report: IARSubmission): Promise<void> {
const {reporterId, reportId, reportedAt} = report;
if (!reporterId) {
@@ -5,7 +5,7 @@ import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {clearTestEmails, createUniqueEmail, findLastTestEmail, listTestEmails} from '@app/api/auth/tests/AuthTestUtils';
import {ReportRepository} from '@app/api/report/ReportRepository';
import {DSAReportEmailVerifications, DSAReportTickets, IARSubmissions} from '@app/api/Tables';
import {DSAReportEmailVerifications, DSAReportTickets, IARReportNotes, IARSubmissions} from '@app/api/Tables';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
@@ -29,6 +29,11 @@ describe('report table TTLs', () => {
expect(IARSubmissions.defaultTtlSeconds).toBeUndefined();
expect(schemaOptions(IARSubmissions.name)).not.toContain('default_time_to_live');
});
it('keeps report notes without a table TTL, so they go only with the report', () => {
expect(IARReportNotes.defaultTtlSeconds).toBeUndefined();
expect(schemaOptions(IARReportNotes.name)).not.toContain('default_time_to_live');
});
});
describe('report table TTLs cover the service windows', () => {
@@ -2,23 +2,36 @@
import type {ReportID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {EnforcementDecisionRepository} from '@app/api/enforcement/EnforcementDecisionRepository';
import {Logger} from '@app/api/Logger';
import {
clearCopiedContextWithEvidence,
deleteReportWithEvidence,
forEachStoredReport,
isReportUnderLegalHold,
type ReportDeletionDeps,
type ReportEvidenceLedger,
referencedObjectKeys,
} from '@app/api/report/ReportDeletion';
import {type IARSubmission, ReportStatus} from '@app/api/report/ReportModels';
import {getReportSearchService} from '@app/api/SearchFactory';
import {EnforcementDecisionEvidence} from '@app/api/Tables';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import {ms} from 'itty-time';
export const REPORT_COMPLAINT_WINDOW_DAYS = 184;
export const CHILD_SAFETY_REPORT_RETENTION_DAYS = 365;
const CHILD_SAFETY_CATEGORY = 'child_safety';
const DECISION_EVIDENCE_RETENTION_MS = EnforcementDecisionEvidence.defaultTtlSeconds! * 1000;
const DEFAULT_SCAN_PAGE_SIZE = 500;
export interface ReportRetentionPolicy {
retentionDays: number;
resolvedRetentionDays: number | null;
}
export interface ReportRetentionDeps extends ReportDeletionDeps {
decisionRepository: Pick<EnforcementDecisionRepository, 'listReportLinks'>;
}
export interface ReportRetentionOptions {
@@ -31,65 +44,136 @@ export interface ReportRetentionOptions {
export interface ReportRetentionSummary {
dryRun: boolean;
scanned: number;
pending: number;
expired: number;
contextExpired: number;
held: number;
deleted: number;
contextCleared: number;
objectsDeleted: number;
sharedObjectsKept: number;
failed: number;
}
type ReportRetentionState = 'expired' | 'held' | 'retained';
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'));
export interface ReportRetentionDates {
deleteAt: number | null;
contextExpiresAt: number | null;
}
function reportRetentionState(report: IARSubmission, now: Date, policy: ReportRetentionPolicy): ReportRetentionState {
const expiresAt = reportExpiresAt(report, policy);
if (Number.isNaN(expiresAt) || expiresAt > now.getTime()) {
type ReportRetentionState = 'expired' | 'context_expired' | 'held' | 'retained';
const KEEP_UNTIL_RESOLVED: ReportRetentionDates = {deleteAt: null, contextExpiresAt: null};
export function reportRetentionDates(
report: IARSubmission,
policy: ReportRetentionPolicy,
decidedAt: Date | null,
): ReportRetentionDates {
if (report.status !== ReportStatus.RESOLVED) {
return KEEP_UNTIL_RESOLVED;
}
const resolvedAt = (report.resolvedAt ?? report.reportedAt).getTime();
if (Number.isNaN(resolvedAt)) {
return KEEP_UNTIL_RESOLVED;
}
const childSafety = report.category === CHILD_SAFETY_CATEGORY;
const ends = [resolvedAt + policy.retentionDays * ms('1 day')];
if (childSafety) {
ends.push(resolvedAt + CHILD_SAFETY_REPORT_RETENTION_DAYS * ms('1 day'));
}
if (decidedAt) {
ends.push(decidedAt.getTime() + DECISION_EVIDENCE_RETENTION_MS);
}
const dropsContextEarly = report.reporterOutcome === 'no_violation' && !childSafety && !decidedAt;
return {
deleteAt: Math.max(...ends),
contextExpiresAt: dropsContextEarly ? resolvedAt + REPORT_COMPLAINT_WINDOW_DAYS * ms('1 day') : null,
};
}
function hasCopiedContext(report: IARSubmission): boolean {
return (
(report.messageContext?.length ?? 0) > 0 ||
report.reportedProfileSnapshot !== null ||
report.additionalInfo !== null
);
}
function reportRetentionState(
report: IARSubmission,
now: Date,
policy: ReportRetentionPolicy,
decidedAt: Date | null,
): ReportRetentionState {
const {deleteAt, contextExpiresAt} = reportRetentionDates(report, policy, decidedAt);
const due =
deleteAt !== null && deleteAt <= now.getTime()
? 'expired'
: contextExpiresAt !== null && contextExpiresAt <= now.getTime() && hasCopiedContext(report)
? 'context_expired'
: null;
if (due === null) {
return 'retained';
}
if (isReportUnderLegalHold(report, now)) {
return 'held';
return isReportUnderLegalHold(report, now) ? 'held' : due;
}
async function latestDecisionByReport(
decisionRepository: ReportRetentionDeps['decisionRepository'],
pageSize: number,
): Promise<Map<bigint, Date>> {
const decidedAt = new Map<bigint, Date>();
let cursor: bigint | undefined;
while (true) {
const page = await decisionRepository.listReportLinks(pageSize, cursor);
for (const decision of page) {
for (const reportId of decision.report_ids ?? []) {
const known = decidedAt.get(reportId);
if (!known || known.getTime() < decision.created_at.getTime()) {
decidedAt.set(reportId, decision.created_at);
}
}
}
if (page.length < pageSize) return decidedAt;
cursor = page[page.length - 1]!.decision_id;
}
return 'expired';
}
export async function processReportRetention(
deps: ReportDeletionDeps,
deps: ReportRetentionDeps,
options: ReportRetentionOptions,
): Promise<ReportRetentionSummary> {
const {reportRepository} = deps;
const {now, dryRun, policy} = options;
const pageSize = options.pageSize ?? DEFAULT_SCAN_PAGE_SIZE;
const summary: ReportRetentionSummary = {
dryRun,
scanned: 0,
pending: 0,
expired: 0,
contextExpired: 0,
held: 0,
deleted: 0,
contextCleared: 0,
objectsDeleted: 0,
sharedObjectsKept: 0,
failed: 0,
};
const decidedAt = await latestDecisionByReport(deps.decisionRepository, pageSize);
const stateOf = (report: IARSubmission) =>
reportRetentionState(report, now, policy, decidedAt.get(report.reportId) ?? null);
const retainedKeys = new Set<string>();
const expiredIds: Array<ReportID> = [];
const due: Array<{reportId: ReportID; state: 'expired' | 'context_expired'}> = [];
await forEachStoredReport(
reportRepository,
(report) => {
summary.scanned++;
const state = reportRetentionState(report, now, policy);
if (state === 'expired') {
expiredIds.push(report.reportId);
if (report.status !== ReportStatus.RESOLVED) {
summary.pending++;
}
const state = stateOf(report);
if (state === 'expired' || state === 'context_expired') {
due.push({reportId: report.reportId, state});
return;
}
if (state === 'held') {
@@ -99,35 +183,37 @@ export async function processReportRetention(
retainedKeys.add(key);
}
},
options.pageSize,
pageSize,
);
summary.expired = expiredIds.length;
const ledger = {retainedKeys, deletedKeys: new Set<string>()};
for (const reportId of expiredIds) {
summary.expired = due.filter((entry) => entry.state === 'expired').length;
summary.contextExpired = due.length - summary.expired;
const ledger: ReportEvidenceLedger = {retainedKeys, deletedKeys: new Set<string>()};
for (const {reportId, state} of due) {
try {
const report = await reportRepository.getReport(reportId);
if (!report) continue;
if (reportRetentionState(report, now, policy) !== 'expired') {
if (stateOf(report) !== state) {
for (const key of referencedObjectKeys(report)) {
retainedKeys.add(key);
}
continue;
}
const result = await deleteReportWithEvidence(deps, report, ledger, {now, dryRun});
const apply = state === 'expired' ? deleteReportWithEvidence : clearCopiedContextWithEvidence;
const result = await apply(deps, report, ledger, {now, dryRun});
summary.objectsDeleted += result.objectsDeleted;
summary.sharedObjectsKept += result.sharedObjectsKept;
summary.deleted++;
if (state === 'expired') {
summary.deleted++;
} else {
summary.contextCleared++;
}
} catch (error) {
summary.failed++;
Logger.error({error, reportId: reportId.toString()}, 'Failed to delete an expired report');
Logger.error({error, reportId: reportId.toString(), state}, 'Failed to apply report retention');
}
}
Logger.info(
{
...summary,
retentionDays: policy.retentionDays,
resolvedRetentionDays: policy.resolvedRetentionDays,
},
{...summary, retentionDays: policy.retentionDays, complaintWindowDays: REPORT_COMPLAINT_WINDOW_DAYS},
'Processed report retention',
);
return summary;
@@ -135,10 +221,15 @@ export async function processReportRetention(
const expireReportSnapshots: WorkerTaskHandler = async () => {
const {reportRepository, storageService} = getWorkerDependencies();
const {days, resolvedDays, dryRun} = Config.reportRetention;
const {days, dryRun} = Config.reportRetention;
await processReportRetention(
{reportRepository, storageService, reportSearchService: getReportSearchService()},
{now: new Date(), dryRun, policy: {retentionDays: days, resolvedRetentionDays: resolvedDays}},
{
reportRepository,
storageService,
reportSearchService: getReportSearchService(),
decisionRepository: new EnforcementDecisionRepository(),
},
{now: new Date(), dryRun, policy: {retentionDays: days}},
);
};
@@ -89,6 +89,7 @@ export function createWorkerEnforcementServices(deps: WorkerDependencies): Worke
adminReportService: new AdminReportService({
apiContext,
reportService,
reportRepository: deps.reportRepository,
guildRepository: deps.guildRepository,
channelRepository: deps.channelRepository,
storageService: deps.storageService,
@@ -3,13 +3,14 @@
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 {ENFORCEMENT_DECISION_COLUMNS, type EnforcementDecisionRow} from '@app/api/database/types/EnforcementTypes';
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
import {
IAR_SUBMISSION_COLUMNS,
type IARMessageContextRow,
type IARSubmissionRow,
} from '@app/api/database/types/ReportTypes';
import type {ReportDeletionDeps} from '@app/api/report/ReportDeletion';
import {EnforcementDecisionRepository} from '@app/api/enforcement/EnforcementDecisionRepository';
import type {IARSubmission} from '@app/api/report/ReportModels';
import {ReportRepository} from '@app/api/report/ReportRepository';
import type {IReportSearchService} from '@app/api/search/IReportSearchService';
@@ -17,11 +18,17 @@ 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 expireReportSnapshots, {
CHILD_SAFETY_REPORT_RETENTION_DAYS,
processReportRetention,
REPORT_COMPLAINT_WINDOW_DAYS,
type ReportRetentionDeps,
type ReportRetentionOptions,
type ReportRetentionPolicy,
reportRetentionDates,
} from '@app/api/worker/tasks/ExpireReportSnapshots';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import type {ReportItemOutcome} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {serializeReportProfileSnapshot} from '@fluxer/schema/src/domains/report/ReportProfileSnapshotSchemas';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {ms} from 'itty-time';
@@ -31,7 +38,7 @@ 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};
const DEFAULT_POLICY: ReportRetentionPolicy = {retentionDays: 365};
let sequence = 1_480_000_000_000_000_000n;
function nextId(): bigint {
@@ -93,12 +100,16 @@ interface SharedAttachment {
}
interface SeedOptions {
ageDays: number;
ageDays?: number;
resolvedDaysAgo?: number;
attachment?: SharedAttachment;
profile?: boolean;
legalHoldUntil?: Date | null;
status?: number;
resolvedDaysAgo?: number;
category?: string;
outcome?: ReportItemOutcome | null;
additionalInfo?: string | null;
reporterId?: bigint | null;
snapshotJson?: string;
}
@@ -116,20 +127,96 @@ function attachmentKey(attachment: SharedAttachment): string {
return makeAttachmentCdnKey(createChannelID(attachment.channelId), attachment.attachmentId, attachment.filename);
}
function storedReport(overrides: Partial<IARSubmission>): IARSubmission {
return {
reportId: createReportID(nextId()),
reportedAt: daysAgo(500),
status: 1,
resolvedAt: daysAgo(10),
category: 'harassment',
reporterOutcome: 'removed',
...overrides,
} as IARSubmission;
}
describe('report retention dates', () => {
const day = DAY_MS;
it('keeps a pending report until it is resolved', () => {
expect(reportRetentionDates(storedReport({status: 0, resolvedAt: null}), DEFAULT_POLICY, null)).toEqual({
deleteAt: null,
contextExpiresAt: null,
});
});
it('deletes an actioned report one year after it was resolved and keeps its context until then', () => {
const report = storedReport({});
expect(reportRetentionDates(report, DEFAULT_POLICY, null)).toEqual({
deleteAt: report.resolvedAt!.getTime() + 365 * day,
contextExpiresAt: null,
});
});
it('drops the context of a no violation report once the complaint window has passed', () => {
const report = storedReport({reporterOutcome: 'no_violation'});
const dates = reportRetentionDates(report, DEFAULT_POLICY, null);
expect(dates.contextExpiresAt).toBe(report.resolvedAt!.getTime() + REPORT_COMPLAINT_WINDOW_DAYS * day);
expect(REPORT_COMPLAINT_WINDOW_DAYS).toBeGreaterThanOrEqual(183);
expect(dates.deleteAt).toBe(report.resolvedAt!.getTime() + 365 * day);
});
it('keeps a child safety report and its context for at least a year whatever the configured period', () => {
const report = storedReport({category: 'child_safety', reporterOutcome: 'no_violation'});
expect(reportRetentionDates(report, {retentionDays: 30}, null)).toEqual({
deleteAt: report.resolvedAt!.getTime() + CHILD_SAFETY_REPORT_RETENTION_DAYS * day,
contextExpiresAt: null,
});
expect(CHILD_SAFETY_REPORT_RETENTION_DAYS).toBeGreaterThanOrEqual(365);
});
it('keeps a report linked to a decision as long as the decision evidence and keeps its context', () => {
const report = storedReport({resolvedAt: daysAgo(300), reporterOutcome: 'no_violation'});
const decidedAt = daysAgo(100);
expect(reportRetentionDates(report, DEFAULT_POLICY, decidedAt)).toEqual({
deleteAt: decidedAt.getTime() + 365 * day,
contextExpiresAt: null,
});
});
it('uses the longer period when the configured one is longer than a floor', () => {
const report = storedReport({category: 'child_safety'});
expect(reportRetentionDates(report, {retentionDays: 730}, null).deleteAt).toBe(
report.resolvedAt!.getTime() + 730 * day,
);
});
it('counts from the filing time for a resolved report without a resolution time', () => {
const report = storedReport({resolvedAt: null});
expect(reportRetentionDates(report, DEFAULT_POLICY, null).deleteAt).toBe(report.reportedAt.getTime() + 365 * day);
});
});
describe('report retention', () => {
let repository: ReportRepository;
let decisions: EnforcementDecisionRepository;
let storage: MockStorageService;
let search: IReportSearchService;
let deps: ReportDeletionDeps;
let deps: ReportRetentionDeps;
beforeEach(() => {
repository = new ReportRepository();
decisions = new EnforcementDecisionRepository();
storage = new MockStorageService();
search = new InMemorySearchProvider().getReportSearchService();
deps = {reportRepository: repository, storageService: storage, reportSearchService: search};
deps = {
reportRepository: repository,
storageService: storage,
reportSearchService: search,
decisionRepository: decisions,
};
});
async function seed(options: SeedOptions): Promise<SeededReport> {
async function seed(options: SeedOptions = {}): Promise<SeededReport> {
const id = nextId();
const reportId = createReportID(id);
let storedAttachmentKey: string | null = null;
@@ -161,14 +248,18 @@ describe('report retention', () => {
guild: null,
});
}
const resolved = options.resolvedDaysAgo !== undefined;
const row = {
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
report_id: id,
reported_at: daysAgo(options.ageDays),
status: options.status ?? (options.resolvedDaysAgo === undefined ? 0 : 1),
resolved_at: options.resolvedDaysAgo === undefined ? null : daysAgo(options.resolvedDaysAgo),
reporter_id: options.reporterId ?? null,
reported_at: daysAgo(options.ageDays ?? (options.resolvedDaysAgo ?? 0) + 5),
status: options.status ?? (resolved ? 1 : 0),
resolved_at: resolved ? daysAgo(options.resolvedDaysAgo!) : null,
reporter_outcome: options.outcome === undefined ? (resolved ? 'removed' : null) : options.outcome,
report_type: 0,
category: 'other',
category: options.category ?? 'other',
additional_info: options.additionalInfo ?? null,
reported_channel_id: options.attachment?.channelId ?? null,
message_context: messageContext,
reported_profile_snapshot: snapshot,
@@ -180,6 +271,30 @@ describe('report retention', () => {
return {reportId, attachmentKey: storedAttachmentKey, profileKey};
}
async function linkDecision(reportIds: Array<ReportID>, createdAt: Date): Promise<void> {
const decisionId = nextId();
const decision = {
...Object.fromEntries(ENFORCEMENT_DECISION_COLUMNS.map((column) => [column, null])),
decision_id: decisionId,
subject_type: 'user',
subject_id: nextId(),
kind: 'warning',
reason_code: DeletionReasons.HARASSMENT_OR_BULLYING,
source: 'report',
automation: 'none',
report_ids: new Set<bigint>(reportIds),
decided_by: nextId(),
created_at: createdAt,
status: 'active',
notice_status: 'sent',
} as EnforcementDecisionRow;
await decisions.create({
decision,
evidence: null,
firstNote: {decision_id: decisionId, note_id: nextId(), author_id: nextId(), body: 'note', created_at: createdAt},
});
}
async function indexedIds(): Promise<Array<string>> {
const result = await search.searchReports('', {}, {limit: 1000});
return result.hits.map((hit) => hit.id).sort();
@@ -199,11 +314,27 @@ describe('report retention', () => {
if (seeded.profileKey) expect(storage.hasObject(REPORTS_BUCKET, seeded.profileKey)).toBe(false);
}
it('deletes a report 365 days after it was filed with its objects, search document and row', async () => {
const expired = await seed({ageDays: 365, attachment: newAttachment(), profile: true});
const kept = await seed({ageDays: 364, attachment: newAttachment(), profile: true});
async function expectContextCleared(seeded: SeededReport): Promise<void> {
const report = await repository.getReport(seeded.reportId);
expect(report).not.toBeNull();
expect(report?.messageContext).toBeNull();
expect(report?.reportedProfileSnapshot).toBeNull();
expect(report?.additionalInfo).toBeNull();
expect(await indexedIds()).toContain(seeded.reportId.toString());
for (const key of [seeded.attachmentKey, seeded.profileKey]) {
if (key) expect(storage.hasObject(REPORTS_BUCKET, key)).toBe(false);
}
}
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
function run(options: Partial<ReportRetentionOptions> = {}) {
return processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY, ...options});
}
it('deletes a report 365 days after it was resolved with its objects, search document and row', async () => {
const expired = await seed({resolvedDaysAgo: 365, attachment: newAttachment(), profile: true});
const kept = await seed({resolvedDaysAgo: 364, attachment: newAttachment(), profile: true});
const summary = await run();
await expectGone(expired);
expect(storage.hasObject(REPORTS_BUCKET, expired.attachmentKey!)).toBe(false);
@@ -211,31 +342,212 @@ describe('report retention', () => {
expect(summary).toEqual({
dryRun: false,
scanned: 2,
pending: 0,
expired: 1,
contextExpired: 0,
held: 0,
deleted: 1,
contextCleared: 0,
objectsDeleted: 2,
sharedObjectsKept: 0,
failed: 0,
});
});
it('deletes expired reports whatever their status', async () => {
const pending = await seed({ageDays: 400, status: 0});
const resolved = await seed({ageDays: 400, status: 1});
it('deletes the notes with an expired report and keeps the notes of a retained one', async () => {
const expired = await seed({resolvedDaysAgo: 365});
const contextExpired = await seed({resolvedDaysAgo: 200, outcome: 'no_violation', additionalInfo: 'details'});
const kept = await seed({resolvedDaysAgo: 10});
for (const {reportId} of [expired, contextExpired, kept]) {
await repository.addNote({
report_id: reportId,
note_id: nextId(),
author_id: nextId(),
body: 'Staff context',
created_at: NOW,
});
}
await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
const summary = await run();
await expectGone(pending);
await expectGone(resolved);
expect(summary).toMatchObject({deleted: 1, contextCleared: 1});
expect(await repository.listNotes(expired.reportId)).toEqual([]);
expect(await repository.listNotes(contextExpired.reportId)).toHaveLength(1);
expect(await repository.listNotes(kept.reportId)).toHaveLength(1);
});
it('keeps the notes in a dry run', async () => {
const expired = await seed({resolvedDaysAgo: 365});
await repository.addNote({
report_id: expired.reportId,
note_id: nextId(),
author_id: nextId(),
body: 'Staff context',
created_at: NOW,
});
await run({dryRun: true});
expect(await repository.listNotes(expired.reportId)).toHaveLength(1);
});
it('keeps a pending report however old it is', async () => {
const pending = await seed({ageDays: 4000, attachment: newAttachment(), profile: true});
const summary = await run({policy: {retentionDays: 1}});
await expectKept(pending);
expect(summary).toMatchObject({scanned: 1, pending: 1, expired: 0, deleted: 0, objectsDeleted: 0});
});
it('drops the copied context of a no violation report after the complaint window and keeps the record', async () => {
const outsideWindow = await seed({
resolvedDaysAgo: REPORT_COMPLAINT_WINDOW_DAYS,
outcome: 'no_violation',
attachment: newAttachment(),
profile: true,
additionalInfo: 'They keep messaging my friends',
});
const insideWindow = await seed({
resolvedDaysAgo: REPORT_COMPLAINT_WINDOW_DAYS - 1,
outcome: 'no_violation',
attachment: newAttachment(),
profile: true,
additionalInfo: 'Still open to a complaint',
});
const actioned = await seed({
resolvedDaysAgo: REPORT_COMPLAINT_WINDOW_DAYS + 10,
attachment: newAttachment(),
profile: true,
});
const summary = await run();
await expectContextCleared(outsideWindow);
const cleared = await repository.getReport(outsideWindow.reportId);
expect(cleared).toMatchObject({status: 1, category: 'other', reporterOutcome: 'no_violation'});
expect((await search.searchReports('friends', {}, {limit: 10})).hits).toHaveLength(0);
await expectKept(insideWindow);
expect((await repository.getReport(insideWindow.reportId))?.additionalInfo).toBe('Still open to a complaint');
await expectKept(actioned);
expect(summary).toMatchObject({
scanned: 3,
expired: 0,
contextExpired: 1,
contextCleared: 1,
deleted: 0,
objectsDeleted: 2,
failed: 0,
});
const again = await run();
expect(again).toMatchObject({contextExpired: 0, contextCleared: 0, objectsDeleted: 0});
});
it('deletes a no violation report one year after it was resolved', async () => {
const expired = await seed({resolvedDaysAgo: 365, outcome: 'no_violation', profile: true});
const summary = await run();
await expectGone(expired);
expect(summary).toMatchObject({expired: 1, deleted: 1, contextExpired: 0});
});
it('keeps an evidence copy that an actioned report shares with a no violation report', async () => {
const shared = newAttachment();
const noViolation = await seed({resolvedDaysAgo: 200, outcome: 'no_violation', attachment: shared});
const actioned = await seed({resolvedDaysAgo: 200, attachment: shared});
const summary = await run();
expect((await repository.getReport(noViolation.reportId))?.messageContext).toBeNull();
await expectKept(actioned);
expect(summary).toMatchObject({contextCleared: 1, sharedObjectsKept: 1, objectsDeleted: 0});
});
it('keeps a child safety report and its context for a year even with a shorter period or no violation', async () => {
const childSafety = await seed({
resolvedDaysAgo: 364,
category: 'child_safety',
outcome: 'no_violation',
attachment: newAttachment(),
profile: true,
additionalInfo: 'Context for a CyberTip',
});
const childSafetyAfterYear = await seed({resolvedDaysAgo: 365, category: 'child_safety', profile: true});
const ordinary = await seed({resolvedDaysAgo: 40, profile: true});
const summary = await run({policy: {retentionDays: 30}});
await expectKept(childSafety);
expect((await repository.getReport(childSafety.reportId))?.additionalInfo).toBe('Context for a CyberTip');
await expectGone(childSafetyAfterYear);
await expectGone(ordinary);
expect(summary).toMatchObject({expired: 2, contextExpired: 0, deleted: 2});
});
it('keeps a child safety report under a legal hold past the year', async () => {
const held = await seed({
resolvedDaysAgo: 500,
category: 'child_safety',
profile: true,
legalHoldUntil: new Date(NOW.getTime() + 30 * DAY_MS),
});
const summary = await run();
await expectKept(held);
expect(summary).toMatchObject({expired: 0, held: 1, deleted: 0});
});
it('keeps a report linked to a decision until the decision evidence expires', async () => {
const linked = await seed({
resolvedDaysAgo: 400,
outcome: 'no_violation',
profile: true,
attachment: newAttachment(),
});
const linkedLongAgo = await seed({resolvedDaysAgo: 800, profile: true});
const unlinked = await seed({resolvedDaysAgo: 400, profile: true});
await linkDecision([linked.reportId], daysAgo(500));
await linkDecision([linked.reportId], daysAgo(100));
await linkDecision([linkedLongAgo.reportId], daysAgo(700));
const summary = await run({pageSize: 1});
await expectKept(linked);
expect((await repository.getReport(linked.reportId))?.messageContext).toHaveLength(1);
await expectGone(linkedLongAgo);
await expectGone(unlinked);
expect(summary).toMatchObject({scanned: 3, expired: 2, contextExpired: 0, deleted: 2});
});
it('deletes nothing when the decision scan cannot finish', async () => {
const expired = await seed({resolvedDaysAgo: 400, profile: true});
await expect(
processReportRetention(
{
...deps,
decisionRepository: {
listReportLinks: async () => {
throw new Error('read timeout');
},
},
},
{now: NOW, dryRun: false, policy: DEFAULT_POLICY},
),
).rejects.toThrow('read timeout');
await expectKept(expired);
});
it('keeps an object that a retained report still uses', async () => {
const shared = newAttachment();
const expired = await seed({ageDays: 500, attachment: shared});
const retained = await seed({ageDays: 10, attachment: shared});
const expired = await seed({resolvedDaysAgo: 500, attachment: shared});
const retained = await seed({resolvedDaysAgo: 10, attachment: shared});
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
const summary = await run();
await expectGone(expired);
await expectKept(retained);
@@ -246,10 +558,10 @@ describe('report retention', () => {
it('deletes an object shared by two expired reports once', async () => {
const shared = newAttachment();
const first = await seed({ageDays: 500, attachment: shared});
const second = await seed({ageDays: 450, attachment: shared});
const first = await seed({resolvedDaysAgo: 500, attachment: shared});
const second = await seed({resolvedDaysAgo: 450, attachment: shared});
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
const summary = await run();
await expectGone(first);
await expectGone(second);
@@ -261,15 +573,15 @@ describe('report retention', () => {
it('keeps a report under a legal hold that ends later and deletes one whose hold has ended', async () => {
const shared = newAttachment();
const held = await seed({
ageDays: 500,
resolvedDaysAgo: 500,
attachment: shared,
profile: true,
legalHoldUntil: new Date(NOW.getTime() + DAY_MS),
});
const holdEnded = await seed({ageDays: 500, profile: true, legalHoldUntil: daysAgo(1)});
const sharingWithHeld = await seed({ageDays: 500, attachment: shared});
const holdEnded = await seed({resolvedDaysAgo: 500, profile: true, legalHoldUntil: daysAgo(1)});
const sharingWithHeld = await seed({resolvedDaysAgo: 500, attachment: shared});
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
const summary = await run();
await expectKept(held);
expect((await repository.getReport(held.reportId))?.legalHoldReason).toBe('Preservation request');
@@ -279,31 +591,61 @@ describe('report retention', () => {
expect(summary).toMatchObject({scanned: 3, expired: 2, held: 1, deleted: 2, sharedObjectsKept: 1});
});
it('changes nothing in a dry run and reports what it would delete', async () => {
const expired = await seed({ageDays: 366, attachment: newAttachment(), profile: true});
const kept = await seed({ageDays: 1, profile: true});
it('keeps the context of a no violation report under a legal hold', async () => {
const held = await seed({
resolvedDaysAgo: 250,
outcome: 'no_violation',
attachment: newAttachment(),
legalHoldUntil: new Date(NOW.getTime() + DAY_MS),
});
const summary = await processReportRetention(deps, {now: NOW, dryRun: true, policy: DEFAULT_POLICY});
const summary = await run();
await expectKept(expired);
await expectKept(kept);
await expectKept(held);
expect((await repository.getReport(held.reportId))?.messageContext).toHaveLength(1);
expect(summary).toMatchObject({held: 1, contextExpired: 0, contextCleared: 0});
});
it('changes nothing in a dry run and counts what it would do to existing reports', async () => {
const expired = await seed({resolvedDaysAgo: 366, attachment: newAttachment(), profile: true});
const contextDue = await seed({
resolvedDaysAgo: 200,
outcome: 'no_violation',
attachment: newAttachment(),
additionalInfo: 'details',
});
const childSafety = await seed({resolvedDaysAgo: 200, category: 'child_safety', outcome: 'no_violation'});
const held = await seed({resolvedDaysAgo: 400, legalHoldUntil: new Date(NOW.getTime() + DAY_MS)});
const pending = await seed({profile: true});
const linked = await seed({resolvedDaysAgo: 400});
await linkDecision([linked.reportId], daysAgo(30));
const summary = await run({dryRun: true});
for (const report of [expired, contextDue, childSafety, held, pending, linked]) {
await expectKept(report);
}
expect((await repository.getReport(contextDue.reportId))?.additionalInfo).toBe('details');
expect(storage.deleteObjectSpy).not.toHaveBeenCalled();
expect(summary).toEqual({
dryRun: true,
scanned: 2,
scanned: 6,
pending: 1,
expired: 1,
held: 0,
contextExpired: 1,
held: 1,
deleted: 1,
objectsDeleted: 2,
contextCleared: 1,
objectsDeleted: 3,
sharedObjectsKept: 0,
failed: 0,
});
});
it('deletes nothing when the scan cannot finish', async () => {
const expired = await seed({ageDays: 400, attachment: newAttachment(), profile: true});
await seed({ageDays: 400});
await seed({ageDays: 400});
const expired = await seed({resolvedDaysAgo: 400, attachment: newAttachment(), profile: true});
await seed({resolvedDaysAgo: 400});
await seed({resolvedDaysAgo: 400});
let pages = 0;
const failingRepository = Object.create(repository) as ReportRepository;
failingRepository.listAllReportsPaginated = async (limit: number, lastReportId?: ReportID) => {
@@ -315,7 +657,12 @@ describe('report retention', () => {
await expect(
processReportRetention(
{...deps, reportRepository: failingRepository},
{now: NOW, dryRun: false, policy: DEFAULT_POLICY, pageSize: 2},
{
now: NOW,
dryRun: false,
policy: DEFAULT_POLICY,
pageSize: 2,
},
),
).rejects.toThrow('read timeout');
@@ -328,11 +675,11 @@ describe('report retention', () => {
const expired: Array<SeededReport> = [];
const kept: Array<SeededReport> = [];
for (let index = 0; index < 7; index++) {
expired.push(await seed({ageDays: 400 + index, profile: true}));
kept.push(await seed({ageDays: index, profile: true}));
expired.push(await seed({resolvedDaysAgo: 400 + index, profile: true}));
kept.push(await seed({resolvedDaysAgo: index, profile: true}));
}
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY, pageSize: 3});
const summary = await run({pageSize: 3});
expect(summary).toMatchObject({scanned: 14, expired: 7, deleted: 7, objectsDeleted: 7, failed: 0});
for (const report of expired) await expectGone(report);
@@ -340,25 +687,57 @@ describe('report retention', () => {
});
it('keeps the row and search document when an object cannot be deleted, then deletes them on the next run', async () => {
const expired = await seed({ageDays: 400, attachment: newAttachment(), profile: true});
const expired = await seed({resolvedDaysAgo: 400, attachment: newAttachment(), profile: true});
storage.configure({shouldFailDelete: true});
const failed = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
const failed = await run();
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, policy: DEFAULT_POLICY});
const retried = await run();
expect(retried).toMatchObject({expired: 1, deleted: 1, failed: 0});
await expectGone(expired);
expect(storage.hasObject(REPORTS_BUCKET, expired.attachmentKey!)).toBe(false);
});
it('keeps the copied context when an object cannot be deleted, then clears it on the next run', async () => {
const due = await seed({resolvedDaysAgo: 200, outcome: 'no_violation', attachment: newAttachment()});
storage.configure({shouldFailDelete: true});
expect(await run()).toMatchObject({contextExpired: 1, contextCleared: 0, failed: 1});
expect((await repository.getReport(due.reportId))?.messageContext).toHaveLength(1);
storage.configure({shouldFailDelete: false});
expect(await run()).toMatchObject({contextExpired: 1, contextCleared: 1, failed: 0});
await expectContextCleared(due);
});
it('does not bring back a report deleted while its context was being cleared', async () => {
const due = await seed({resolvedDaysAgo: 200, outcome: 'no_violation', additionalInfo: 'details'});
const deletingRepository = Object.create(repository) as ReportRepository;
deletingRepository.clearCopiedContext = async (reportId, reportType) => {
await repository.deleteReport(reportId);
return repository.clearCopiedContext(reportId, reportType);
};
await processReportRetention(
{...deps, reportRepository: deletingRepository},
{
now: NOW,
dryRun: false,
policy: DEFAULT_POLICY,
},
);
expect(await repository.getReport(due.reportId)).toBeNull();
});
it('keeps the row when the search document cannot be deleted', async () => {
const expired = await seed({ageDays: 400});
const expired = await seed({resolvedDaysAgo: 400});
const failingSearch = Object.create(search) as IReportSearchService;
failingSearch.deleteReport = async () => {
throw new Error('search unavailable');
@@ -366,7 +745,11 @@ describe('report retention', () => {
const summary = await processReportRetention(
{...deps, reportSearchService: failingSearch},
{now: NOW, dryRun: false, policy: DEFAULT_POLICY},
{
now: NOW,
dryRun: false,
policy: DEFAULT_POLICY,
},
);
expect(summary).toMatchObject({deleted: 0, failed: 1});
@@ -374,11 +757,15 @@ describe('report retention', () => {
});
it('deletes rows when search is not configured', async () => {
const expired = await seed({ageDays: 400, profile: true});
const expired = await seed({resolvedDaysAgo: 400, profile: true});
const summary = await processReportRetention(
{...deps, reportSearchService: null},
{now: NOW, dryRun: false, policy: DEFAULT_POLICY},
{
now: NOW,
dryRun: false,
policy: DEFAULT_POLICY,
},
);
expect(summary).toMatchObject({deleted: 1, failed: 0});
@@ -389,8 +776,8 @@ describe('report retention', () => {
it('skips a report that was put on hold after the scan', async () => {
const shared = newAttachment();
const seeded = [
await seed({ageDays: 400, attachment: shared, profile: true}),
await seed({ageDays: 400, attachment: shared}),
await seed({resolvedDaysAgo: 400, attachment: shared, profile: true}),
await seed({resolvedDaysAgo: 400, attachment: shared}),
];
const scanOrder = (await repository.listAllReportsPaginated(10)).map((report) => report.reportId);
const [first, second] = seeded.sort((a, b) => scanOrder.indexOf(a.reportId) - scanOrder.indexOf(b.reportId)) as [
@@ -408,7 +795,11 @@ describe('report retention', () => {
const summary = await processReportRetention(
{...deps, reportRepository: holdingRepository},
{now: NOW, dryRun: false, policy: DEFAULT_POLICY},
{
now: NOW,
dryRun: false,
policy: DEFAULT_POLICY,
},
);
await expectKept(first);
@@ -419,7 +810,7 @@ describe('report retention', () => {
it('keeps an object that a report filed during the run stored again', async () => {
const shared = newAttachment();
const expired = await seed({ageDays: 400, attachment: shared, profile: true});
const expired = await seed({resolvedDaysAgo: 400, attachment: shared, profile: true});
const rewritingStorage = Object.create(storage) as MockStorageService;
rewritingStorage.getObjectMetadata = async (bucket: string, key: string) => {
const metadata = await storage.getObjectMetadata(bucket, key);
@@ -431,7 +822,11 @@ describe('report retention', () => {
const summary = await processReportRetention(
{...deps, storageService: rewritingStorage},
{now: NOW, dryRun: false, policy: DEFAULT_POLICY},
{
now: NOW,
dryRun: false,
policy: DEFAULT_POLICY,
},
);
await expectGone(expired);
@@ -440,76 +835,25 @@ 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 expired = await seed({resolvedDaysAgo: 400, profile: true, snapshotJson: '{"not":"a snapshot"'});
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
const summary = await run();
expect(summary).toMatchObject({deleted: 1, objectsDeleted: 1});
await expectGone(expired);
});
function run(options: Partial<ReportRetentionOptions> = {}) {
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 expired = await seed({resolvedDaysAgo: 30, profile: true});
const kept = await seed({resolvedDaysAgo: 29, profile: true});
const summary = await run({policy: {retentionDays: 30, resolvedRetentionDays: null}});
const summary = await run({policy: {retentionDays: 30}});
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();
@@ -519,8 +863,9 @@ describe('report retention', () => {
report_id: BigInt(reportId),
reporter_id: reporterId,
reported_user_id: reportedUserId,
reported_at: daysAgo(400),
status: 0,
reported_at: daysAgo(420),
status: 1,
resolved_at: daysAgo(400),
report_type: 1,
category: 'harassment',
} as IARSubmissionRow);
@@ -528,7 +873,7 @@ describe('report retention', () => {
reporter_id: createUserID(reporterId),
reported_user_id: createUserID(reportedUserId),
report_id: reportId,
reported_at: daysAgo(400),
reported_at: daysAgo(420),
};
expect(await repository.reserveUserReportByReporter(reservation)).toBe(true);
@@ -559,9 +904,10 @@ describe('report retention', () => {
}
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});
expect(configured).toEqual({days: 365, dryRun: false});
const expired = await seed({resolvedDaysAgo: 366, attachment: newAttachment(), profile: true});
const kept = await seed({resolvedDaysAgo: 364, profile: true});
const pending = await seed({profile: true});
await runDailyTask();
@@ -569,11 +915,12 @@ describe('report retention', () => {
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();
expect(await repository.getReport(pending.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});
const expired = await seed({resolvedDaysAgo: 366, attachment: newAttachment(), profile: true});
await runDailyTask();
@@ -581,17 +928,18 @@ describe('report retention', () => {
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});
it('reads the day setting from the config and honors decisions', async () => {
Object.assign(Config.reportRetention, {days: 60});
const expired = await seed({resolvedDaysAgo: 60});
const kept = await seed({resolvedDaysAgo: 59});
const linked = await seed({resolvedDaysAgo: 90});
await linkDecision([linked.reportId], daysAgo(90));
await runDailyTask();
expect(await repository.getReport(byAge.reportId)).toBeNull();
expect(await repository.getReport(byResolution.reportId)).toBeNull();
expect(await repository.getReport(expired.reportId)).toBeNull();
expect(await repository.getReport(kept.reportId)).not.toBeNull();
expect(await repository.getReport(linked.reportId)).not.toBeNull();
});
});
});
+94 -206
View File
@@ -2,24 +2,17 @@
import {mkdirSync, writeFileSync} from 'node:fs';
import {dirname, join, relative} from 'node:path';
interface ColorFamily {
hue: number;
saturation: number;
useSaturationFactor: boolean;
}
interface ScaleStop {
name: string;
position?: number;
}
interface Scale {
family: string;
range: [number, number];
curve: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
stops: Array<ScaleStop>;
}
import {
BRAND_PRESSED_RATIO,
brandTintMix,
COLOR_FAMILIES,
COLOR_SCALES,
type ColorFamily,
type Scale,
scaleStopLightness,
THEME_PALETTES,
} from '@app/features/theme/utils/ColorSystemPalette';
import {ThemeTypes} from '@fluxer/constants/src/UserConstants';
interface TokenDef {
name?: string;
@@ -295,142 +288,8 @@ const LIGHT_CODE_TOKENS: Array<TokenDef> = [
{name: '--ansi-bg-bright-white', value: 'color-mix(in srgb, hsl(0, 0%, 90%) 90%, var(--code-block-bg) 10%)'},
];
const CONFIG: Config = {
families: {
neutralDark: {hue: 258, saturation: 10, useSaturationFactor: true},
neutralLight: {hue: 220, saturation: 10, useSaturationFactor: true},
brand: {hue: 242, saturation: 70, useSaturationFactor: true},
link: {hue: 198, saturation: 92, useSaturationFactor: true},
accentPurple: {hue: 278, saturation: 85, useSaturationFactor: true},
statusOnline: {hue: 152, saturation: 72, useSaturationFactor: true},
legacyDark: {hue: 220, saturation: 13, useSaturationFactor: true},
legacyLink: {hue: 210, saturation: 100, useSaturationFactor: true},
legacyAccentPurple: {hue: 270, saturation: 80, useSaturationFactor: true},
legacyStatusOnline: {hue: 142, saturation: 76, useSaturationFactor: true},
statusIdle: {hue: 45, saturation: 93, useSaturationFactor: true},
statusDnd: {hue: 0, saturation: 84, useSaturationFactor: true},
statusOffline: {hue: 218, saturation: 11, useSaturationFactor: true},
statusDanger: {hue: 1, saturation: 77, useSaturationFactor: true},
textCode: {hue: 340, saturation: 50, useSaturationFactor: true},
brandIcon: {hue: 38, saturation: 92, useSaturationFactor: true},
},
scales: {
darkSurface: {
family: 'neutralDark',
range: [5, 24],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-lighter', position: 0.22},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.3},
{name: '--background-header-primary-hover', position: 0.85},
],
},
coalSurface: {
family: 'neutralDark',
range: [1, 12],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.3},
{name: '--background-header-primary-hover', position: 0.85},
],
},
darkText: {
family: 'neutralDark',
range: [60, 96],
curve: 'easeInOut',
stops: [
{name: '--text-tertiary-secondary', position: 0},
{name: '--text-tertiary-muted', position: 0.2},
{name: '--text-tertiary', position: 0.38},
{name: '--text-primary-muted', position: 0.55},
{name: '--text-chat-muted', position: 0.55},
{name: '--text-secondary', position: 0.72},
{name: '--text-chat', position: 0.82},
{name: '--text-primary', position: 1},
],
},
lightSurface: {
family: 'neutralLight',
range: [86, 98.5],
curve: 'easeIn',
stops: [
{name: '--background-header-primary-hover', position: 0},
{name: '--background-header-primary', position: 0.12},
{name: '--background-header-secondary', position: 0.2},
{name: '--guild-list-foreground', position: 0.35},
{name: '--background-tertiary', position: 0.42},
{name: '--background-channel-header', position: 0.5},
{name: '--background-secondary-alt', position: 0.63},
{name: '--background-secondary', position: 0.74},
{name: '--background-secondary-lighter', position: 0.83},
{name: '--background-textarea', position: 0.88},
{name: '--background-primary', position: 1},
],
},
lightText: {
family: 'neutralLight',
range: [15, 54],
curve: 'easeOut',
stops: [
{name: '--text-primary', position: 0},
{name: '--text-chat', position: 0.08},
{name: '--text-secondary', position: 0.28},
{name: '--text-chat-muted', position: 0.45},
{name: '--text-primary-muted', position: 0.45},
{name: '--text-tertiary', position: 0.6},
{name: '--text-tertiary-secondary', position: 0.75},
{name: '--text-tertiary-muted', position: 0.85},
],
},
legacyDarkSurface: {
family: 'legacyDark',
range: [5, 26],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-lighter', position: 0.22},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.3},
{name: '--background-header-primary-hover', position: 0.85},
],
},
legacyDarkText: {
family: 'legacyDark',
range: [52, 96],
curve: 'easeInOut',
stops: [
{name: '--text-tertiary-secondary', position: 0},
{name: '--text-tertiary-muted', position: 0.2},
{name: '--text-tertiary', position: 0.38},
{name: '--text-primary-muted', position: 0.55},
{name: '--text-chat-muted', position: 0.55},
{name: '--text-secondary', position: 0.72},
{name: '--text-chat', position: 0.82},
{name: '--text-primary', position: 1},
],
},
},
families: COLOR_FAMILIES,
scales: COLOR_SCALES,
tokens: {
root: [
{scale: 'darkSurface'},
@@ -447,7 +306,7 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
{name: '--panel-control-divider', family: 'neutralDark', saturation: 30, lightness: 55, alpha: 0.35},
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.04)'},
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.05},
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.1},
{name: '--background-modifier-selected', ...THEME_PALETTES[ThemeTypes.DARK].selectedOverlay},
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.15},
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.22},
{name: '--control-button-normal-bg', value: 'transparent'},
@@ -480,6 +339,41 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
lightness: 100,
instanceOverride: '--instance-brand-primary-fill',
},
{
name: '--brand-primary-shade',
hue: 0,
saturation: 0,
lightness: 0,
instanceOverride: '--instance-brand-primary-shade',
},
{
name: '--brand-primary-pressed',
value: `color-mix(in srgb, var(--brand-primary) ${BRAND_PRESSED_RATIO}%, var(--brand-primary-shade))`,
},
{
name: '--brand-primary-foreground',
family: 'brand',
lightness: 55,
instanceOverride: '--instance-brand-primary-foreground',
},
{
name: '--brand-primary-light-fill',
hue: 0,
saturation: 0,
lightness: 100,
instanceOverride: '--instance-brand-primary-light-fill',
},
{
name: '--brand-tint',
value: brandTintMix(THEME_PALETTES[ThemeTypes.DARK].brandTint, 'rest'),
instanceOverride: '--instance-brand-tint',
},
{
name: '--brand-tint-hover',
value: brandTintMix(THEME_PALETTES[ThemeTypes.DARK].brandTint, 'hover'),
instanceOverride: '--instance-brand-tint-hover',
},
{name: '--brand-tint-border', value: 'var(--brand-primary)', instanceOverride: '--instance-brand-tint-border'},
{name: '--status-online', family: 'statusOnline', lightness: 40},
{name: '--status-idle', family: 'statusIdle', lightness: 50},
{name: '--status-dnd', family: 'statusDnd', lightness: 60},
@@ -500,6 +394,19 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
lightness: 98,
instanceOverride: '--instance-text-on-brand-primary',
},
{
name: '--text-on-brand-primary-light',
hue: 0,
saturation: 0,
lightness: 98,
instanceOverride: '--instance-text-on-brand-primary-light',
},
{
name: '--text-on-brand-tint',
value: 'var(--text-on-brand-primary)',
instanceOverride: '--instance-text-on-brand-tint',
},
{name: '--text-on-dark', hue: 0, saturation: 0, lightness: 98},
...DARK_CODE_TOKENS,
{name: '--text-selection', hue: 198, saturation: 92, useSaturationFactor: true, lightness: 70, alpha: 0.35},
{name: '--markup-mention-text', value: 'var(--text-link)'},
@@ -623,7 +530,7 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
{name: '--panel-control-divider', family: 'neutralLight', saturation: 30, lightness: 35, alpha: 0.2},
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.65)'},
{name: '--background-modifier-hover', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.05},
{name: '--background-modifier-selected', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
{name: '--background-modifier-selected', ...THEME_PALETTES[ThemeTypes.LIGHT].selectedOverlay},
{name: '--background-modifier-accent', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.22},
{name: '--background-modifier-accent-focus', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.32},
{name: '--control-button-normal-bg', value: 'transparent'},
@@ -667,6 +574,22 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
{name: '--plutonium-text', value: 'var(--text-on-brand-primary)'},
{name: '--plutonium-icon', family: 'brandIcon', lightness: 45},
{name: '--invite-verified-icon-color', value: 'var(--brand-primary)'},
{
name: '--brand-tint',
value: brandTintMix(THEME_PALETTES[ThemeTypes.LIGHT].brandTint, 'rest'),
instanceOverride: '--instance-brand-tint',
},
{
name: '--brand-tint-hover',
value: brandTintMix(THEME_PALETTES[ThemeTypes.LIGHT].brandTint, 'hover'),
instanceOverride: '--instance-brand-tint-hover',
},
{
name: '--brand-tint-border',
value: 'color-mix(in srgb, var(--brand-primary) 45%, transparent 55%)',
instanceOverride: '--instance-brand-tint-border',
},
{name: '--text-on-brand-tint', value: 'var(--brand-primary)', instanceOverride: '--instance-text-on-brand-tint'},
{name: '--border-color', family: 'neutralLight', lightness: 40, alpha: 0.15},
{name: '--border-color-hover', family: 'neutralLight', lightness: 40, alpha: 0.25},
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.4},
@@ -707,8 +630,10 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
],
coal: [
{scale: 'coalSurface'},
{name: '--background-secondary', value: 'var(--background-primary)'},
{name: '--background-secondary-lighter', value: 'var(--background-primary)'},
...Object.entries(THEME_PALETTES[ThemeTypes.COAL].surfaceAliases).map(([name, target]) => ({
name,
value: `var(${target})`,
})),
{
name: '--panel-control-bg',
value: `color-mix(
@@ -721,7 +646,7 @@ hsl(258, calc(10% * var(--saturation-factor)), 0%) 10%
{name: '--panel-control-divider', family: 'neutralDark', saturation: 20, lightness: 25, alpha: 0.28},
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.06)'},
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.04},
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.08},
{name: '--background-modifier-selected', ...THEME_PALETTES[ThemeTypes.COAL].selectedOverlay},
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 10, lightness: 65, alpha: 0.18},
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 10, lightness: 70, alpha: 0.26},
{name: '--control-button-normal-bg', value: 'transparent'},
@@ -776,7 +701,7 @@ hsl(220, calc(13% * var(--saturation-factor)), 2%) 10%
{name: '--panel-control-border', family: 'legacyDark', saturation: 30, lightness: 65, alpha: 0.45},
{name: '--panel-control-divider', family: 'legacyDark', saturation: 30, lightness: 55, alpha: 0.35},
{name: '--background-modifier-hover', family: 'legacyDark', lightness: 100, alpha: 0.05},
{name: '--background-modifier-selected', family: 'legacyDark', lightness: 100, alpha: 0.1},
{name: '--background-modifier-selected', ...THEME_PALETTES[ThemeTypes.DARK_LEGACY].selectedOverlay},
{name: '--background-modifier-accent', family: 'legacyDark', saturation: 13, lightness: 80, alpha: 0.15},
{name: '--background-modifier-accent-focus', family: 'legacyDark', saturation: 13, lightness: 80, alpha: 0.22},
{name: '--control-button-hover-bg', family: 'legacyDark', lightness: 22},
@@ -820,48 +745,13 @@ interface OutputToken {
instanceOverride?: string;
}
function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}
function applyCurve(curve: Scale['curve'], t: number): number {
switch (curve) {
case 'easeIn':
return t * t;
case 'easeOut':
return 1 - (1 - t) * (1 - t);
case 'easeInOut':
if (t < 0.5) {
return 2 * t * t;
}
return 1 - 2 * (1 - t) * (1 - t);
default:
return t;
}
}
function buildScaleTokens(scale: Scale): Array<OutputToken> {
const lastIndex = Math.max(scale.stops.length - 1, 1);
const tokens: Array<OutputToken> = [];
for (let i = 0; i < scale.stops.length; i++) {
const stop = scale.stops[i];
let pos: number;
if (stop.position !== undefined) {
pos = clamp01(stop.position);
} else {
pos = i / lastIndex;
}
const eased = applyCurve(scale.curve, pos);
let lightness = scale.range[0] + (scale.range[1] - scale.range[0]) * eased;
lightness = Math.round(lightness * 1000) / 1000;
tokens.push({
type: 'tone',
name: stop.name,
family: scale.family,
lightness,
});
}
return tokens;
return scale.stops.map((stop, index) => ({
type: 'tone',
name: stop.name,
family: scale.family,
lightness: scaleStopLightness(scale, index),
}));
}
function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Array<OutputToken> {
@@ -881,6 +771,7 @@ function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Arr
type: 'literal',
name: def.name!,
value: def.value.trim(),
instanceOverride: def.instanceOverride,
});
} else {
tokens.push({
@@ -945,11 +836,8 @@ function formatTone(token: OutputToken, families: Record<string, ColorFamily>):
}
function formatValue(token: OutputToken, families: Record<string, ColorFamily>): string {
if (token.type === 'tone') {
const tone = formatTone(token, families);
return token.instanceOverride ? `var(${token.instanceOverride}, ${tone})` : tone;
}
return token.value!.trim();
const value = token.type === 'tone' ? formatTone(token, families) : token.value!.trim();
return token.instanceOverride ? `var(${token.instanceOverride}, ${value})` : value;
}
function renderBlock(selector: string, tokens: Array<OutputToken>, families: Record<string, ColorFamily>): string {
+2 -2
View File
@@ -201,7 +201,7 @@ html.platform-native .fluxer-boot-app {
.fluxer-boot-item[data-selected='1'] .fluxer-boot-fluxer-icon {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.fluxer-boot-action-icon {
display: flex;
@@ -218,7 +218,7 @@ html.platform-native .fluxer-boot-app {
.fluxer-boot-item[data-selected='1'] .fluxer-boot-action-icon {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.fluxer-boot-glyph-lg {
height: 1.75rem;
@@ -650,6 +650,7 @@ export interface AccessibilitySettings {
preserveEditDraft: boolean;
stayInteractiveWhenUnfocused: boolean;
scrollToBottomOnMessageSend: boolean;
doubleClickMessageActions: boolean;
sequentialFileSend: boolean;
showNeko: boolean;
keepNekoStill: boolean;
@@ -756,6 +757,7 @@ class Accessibility {
preserveEditDraft = false;
stayInteractiveWhenUnfocused = false;
scrollToBottomOnMessageSend = true;
doubleClickMessageActions = true;
sequentialFileSend = false;
showNeko = false;
keepNekoStill = false;
@@ -858,6 +860,7 @@ class Accessibility {
'preserveEditDraft',
'stayInteractiveWhenUnfocused',
'scrollToBottomOnMessageSend',
'doubleClickMessageActions',
'sequentialFileSend',
],
toMessage: (s) => ({
@@ -920,6 +923,7 @@ class Accessibility {
preserveEditDraft: s.preserveEditDraft,
stayInteractiveWhenUnfocused: s.stayInteractiveWhenUnfocused,
scrollToBottomOnMessageSend: s.scrollToBottomOnMessageSend,
doubleClickMessageActions: s.doubleClickMessageActions,
sequentialFileSend: s.sequentialFileSend,
}),
applyMessage: (s, m) => {
@@ -1012,6 +1016,7 @@ class Accessibility {
if (m.stayInteractiveWhenUnfocused !== undefined)
s.stayInteractiveWhenUnfocused = m.stayInteractiveWhenUnfocused;
if (m.scrollToBottomOnMessageSend !== undefined) s.scrollToBottomOnMessageSend = m.scrollToBottomOnMessageSend;
if (m.doubleClickMessageActions !== undefined) s.doubleClickMessageActions = m.doubleClickMessageActions;
if (m.sequentialFileSend !== undefined) s.sequentialFileSend = m.sequentialFileSend;
},
});
@@ -1335,6 +1340,8 @@ class Accessibility {
this.stayInteractiveWhenUnfocused = validated.stayInteractiveWhenUnfocused;
if (validated.scrollToBottomOnMessageSend !== undefined)
this.scrollToBottomOnMessageSend = validated.scrollToBottomOnMessageSend;
if (validated.doubleClickMessageActions !== undefined)
this.doubleClickMessageActions = validated.doubleClickMessageActions;
if (validated.sequentialFileSend !== undefined) this.sequentialFileSend = validated.sequentialFileSend;
if (validated.showNeko !== undefined && validated.showNeko !== this.showNeko) {
this.showNeko = validated.showNeko;
@@ -1438,6 +1445,7 @@ class Accessibility {
preserveEditDraft: data.preserveEditDraft ?? this.preserveEditDraft,
stayInteractiveWhenUnfocused: data.stayInteractiveWhenUnfocused ?? this.stayInteractiveWhenUnfocused,
scrollToBottomOnMessageSend: data.scrollToBottomOnMessageSend ?? this.scrollToBottomOnMessageSend,
doubleClickMessageActions: data.doubleClickMessageActions ?? this.doubleClickMessageActions,
sequentialFileSend: data.sequentialFileSend ?? this.sequentialFileSend,
showNeko: data.showNeko ?? this.showNeko,
keepNekoStill: data.keepNekoStill ?? this.keepNekoStill,
@@ -94,6 +94,7 @@ export const ConfirmModal = observer(
isEditing: false,
isHighlight: false,
disableContextMenu: true,
disableDoubleClick: true,
disableContextMenuTracking: true,
contextMenuOpen: false,
}),
@@ -137,5 +137,5 @@
height: 1.25rem;
border-radius: 50%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
@@ -298,7 +298,7 @@
}
.channelItemAutocompleteHighlight {
box-shadow: 0 0 0 0.125rem var(--brand-primary);
box-shadow: 0 0 0 0.125rem var(--brand-primary-foreground);
}
.categoryContent {
@@ -27,5 +27,5 @@
}
:global(.theme-light) .dropIndicatorValid {
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
}
@@ -13,7 +13,7 @@
}
:global(.theme-light) .channelBadgeSelectedIcon {
color: #fff;
color: var(--brand-primary-fill);
}
.favoriteItemContainer {
@@ -85,7 +85,7 @@
align-items: center;
justify-content: center;
border-radius: 624.9375rem;
background-color: white;
background-color: var(--brand-primary-fill);
font-size: 0.75rem;
font-weight: 600;
color: var(--brand-primary);
@@ -219,7 +219,7 @@
.guildIconSelected.guildIconNoImage {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
@media (hover: hover) and (pointer: fine) {
@@ -231,7 +231,7 @@
.guildListItem.contextMenuHover .guildIconNoImage {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
}
@@ -363,7 +363,7 @@
.fluxerButtonIconSelected {
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
@media (hover: hover) and (pointer: fine) {
@@ -374,7 +374,7 @@
.fluxerButtonIcon:hover,
.fluxerButton.contextMenuHover .fluxerButtonIcon {
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
}
@@ -782,10 +782,10 @@
:global(.theme-light) .dropIndicatorTop::before,
:global(.theme-light) .dropIndicatorBottom::after,
:global(.theme-light) .guildListDropZoneActive::after {
background: var(--brand-primary);
background: var(--brand-primary-foreground);
}
:global(.theme-light) .combinePreviewGrid {
background-color: color-mix(in srgb, var(--background-secondary) 92%, var(--brand-primary) 8%);
outline-color: var(--brand-primary);
outline-color: var(--brand-primary-foreground);
}
@@ -37,7 +37,7 @@ export const NAGBAR_TONES: Readonly<Record<NagbarToneKind, NagbarTone>> = Object
}),
[NagbarToneKind.DANGER]: Object.freeze({
backgroundColor: 'var(--status-danger)',
textColor: 'var(--text-on-brand-primary)',
textColor: 'var(--text-on-dark)',
}),
[NagbarToneKind.ALERT]: Object.freeze({backgroundColor: '#ea580c', textColor: '#ffffff'}),
[NagbarToneKind.PREMIUM]: Object.freeze({backgroundColor: '#f97316', textColor: '#ffffff'}),
@@ -22,7 +22,7 @@
height: 0.125rem;
width: 100%;
border-radius: 624.9375rem;
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
transition:
transform 150ms,
opacity 150ms;
@@ -19,5 +19,5 @@
.iconTextSelected {
height: 1.25rem;
width: 1.25rem;
color: white;
color: var(--brand-primary-fill);
}
@@ -66,7 +66,9 @@ export const DiscoveryButton = observer(() => {
animate={{borderRadius: isHovering || isSelected ? '30%' : '50%'}}
initial={{borderRadius: isHovering || isSelected ? '30%' : '50%'}}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.07, ease: 'easeOut'}}
style={isSelected ? {backgroundColor: 'var(--brand-primary)', color: 'white'} : undefined}
style={
isSelected ? {backgroundColor: 'var(--brand-primary)', color: 'var(--brand-primary-fill)'} : undefined
}
data-flx="app.sidebar-nav.discovery-button.div--2"
>
<CompassIcon
@@ -269,10 +269,10 @@
:global(.theme-light) .dropIndicatorTop::before,
:global(.theme-light) .dropIndicatorBottom::after {
background: var(--brand-primary);
background: var(--brand-primary-foreground);
}
:global(.theme-light) .dropIndicatorInside .collapsedFolder,
:global(.theme-light) .dropIndicatorInside .folderHeaderButton {
outline-color: var(--brand-primary);
outline-color: var(--brand-primary-foreground);
}
@@ -15,7 +15,7 @@
width: 6rem;
height: 6rem;
margin-bottom: 1.5rem;
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.content {
@@ -157,12 +157,12 @@
.editIcon {
height: 1.25rem;
width: 1.25rem;
color: white;
color: var(--brand-primary-fill);
}
.editLabel {
font-weight: 500;
color: white;
color: var(--brand-primary-fill);
}
.section {
@@ -32,7 +32,7 @@
padding: 0.25rem 0.5rem;
background-color: var(--brand-primary);
border-radius: 624.9375rem;
color: white;
color: var(--brand-primary-fill);
font-size: 0.875rem;
line-height: 1;
}
@@ -205,7 +205,7 @@
.item[data-selected='true'] .fluxerIcon {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.fluxerIconGlyph {
@@ -248,7 +248,7 @@
.item[data-selected='true'] .actionIcon {
border-radius: 30%;
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.actionIconGlyph {
@@ -270,6 +270,21 @@ describe('runtimeSnapshotFromDiscovery', () => {
).toThrow(/appPublic\.branding\.icon_url/u);
});
test('treats a black or malformed theme colour as unset and normalises the rest', () => {
const snapshot = runtimeSnapshotFromDiscovery(parsedDocument('t.test'));
const themeColorOf = (theme_color: unknown) =>
requireRuntimeConfigSnapshot({
...snapshot,
appPublic: {...snapshot.appPublic, branding: {...snapshot.appPublic.branding, theme_color}},
}).appPublic.branding.theme_color;
for (const unset of ['#000000', '#000', '000000', '', 'rgb(1, 2, 3)', 'rebeccapurple', 0, null]) {
expect(themeColorOf(unset)).toBeNull();
}
expect(themeColorOf('#FF0000')).toBe('#ff0000');
expect(themeColorOf(' f00 ')).toBe('#ff0000');
expect(themeColorOf('#000001')).toBe('#000001');
});
test('carries the advertised domain migration', () => {
const domainMigration = {
enabled: true,
@@ -14,6 +14,7 @@ import {
} from '@fluxer/instance_bootstrap/src/Discovery';
import {InstanceEndpointKind, normalizeInstanceEndpoint} from '@fluxer/instance_bootstrap/src/EndpointNormalization';
import {CanonicalNetworkProtocol} from '@fluxer/instance_bootstrap/src/NetworkOrigin';
import {normalizeThemeColor} from '@fluxer/instance_bootstrap/src/ThemeColor';
import {
AGE_POLICY_ACTIONS,
type InstanceAgePolicy,
@@ -413,7 +414,7 @@ function readAppPublic(value: unknown): InstanceAppPublic {
logo_url: readNullableAbsoluteHttpUrl(branding, 'logo_url', 'appPublic.branding.logo_url'),
wordmark_url: readNullableAbsoluteHttpUrl(branding, 'wordmark_url', 'appPublic.branding.wordmark_url'),
favicon_url: readNullableAbsoluteHttpUrl(branding, 'favicon_url', 'appPublic.branding.favicon_url'),
theme_color: readNullableNonEmptyString(branding, 'theme_color', 'appPublic.branding.theme_color'),
theme_color: normalizeThemeColor(branding.theme_color),
status_page_url: readOptionalText(branding, 'status_page_url'),
status_page_incident_history_url: readOptionalText(branding, 'status_page_incident_history_url'),
premium_product_name: readOptionalText(branding, 'premium_product_name') ?? DEFAULT_PREMIUM_PRODUCT_NAME,
@@ -6,7 +6,7 @@
--account-profile-slot-width: 14.875rem;
--account-profile-instance-badge-size: 3rem;
--account-profile-state-badge-size: 2.25rem;
--account-profile-name-color: var(--text-on-brand-primary);
--account-profile-name-color: var(--text-on-dark);
--account-profile-domain-height: 0;
--account-profile-domain-gap: 0;
--account-profile-name-gap: 0.625rem;
@@ -100,7 +100,7 @@
.noAccounts {
margin-bottom: 1rem;
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.25rem;
@@ -49,7 +49,7 @@
.checkIcon {
height: 0.625rem;
width: 0.625rem;
color: white;
color: var(--brand-primary-fill);
}
.code {
@@ -157,7 +157,7 @@ img.entityIcon {
.themeIcon {
height: 2.5rem;
width: 2.5rem;
color: white;
color: var(--brand-primary-fill);
}
.form {
@@ -98,7 +98,7 @@
width: 1.5rem;
height: 1.5rem;
border: 0.1875rem solid var(--background-modifier-accent);
border-top-color: var(--brand-primary);
border-top-color: var(--brand-primary-foreground);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@@ -53,5 +53,5 @@
.audioIcon {
height: 1rem;
width: 1rem;
color: white;
color: var(--brand-primary-fill);
}
@@ -58,11 +58,11 @@
}
:global(.theme-light) .gifButton:hover {
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
}
:global(.theme-light) .gifButtonSelected {
border-color: var(--brand-primary);
border-color: var(--brand-primary-foreground);
}
.gifVideoWrapper {
@@ -317,5 +317,5 @@
:global(.theme-light) .dropIndicatorLeft::before,
:global(.theme-light) .dropIndicatorRight::after {
background: var(--brand-primary);
background: var(--brand-primary-foreground);
}
@@ -11,7 +11,12 @@ import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHo
import {isMediaOnlyEmbed} from '@app/features/channel/components/embeds/EmbedRenderUtils';
import {MessageActionBar} from '@app/features/channel/components/MessageActionBar';
import {MessageActionBottomSheet} from '@app/features/channel/components/MessageActionBottomSheet';
import {requestDeleteMessage} from '@app/features/channel/components/MessageActionUtils';
import {
getMessagePermissions,
requestDeleteMessage,
requestMessageReply,
startMessageEdit,
} from '@app/features/channel/components/MessageActionUtils';
import {useMessageHoverState} from '@app/features/channel/components/MessageHoverState';
import {MessageViewContextProvider} from '@app/features/channel/components/MessageViewContext';
import type {Channel} from '@app/features/channel/models/Channel';
@@ -100,6 +105,8 @@ const MOVEMENT_THRESHOLD = 10;
const HIGHLIGHT_DELAY = 100;
const SUPPRESS_POST_LONG_PRESS_CLICK_MS = 750;
const NESTED_LONG_PRESS_OWNER_SELECTOR = '[data-long-press-owner="true"]';
const DOUBLE_CLICK_INTERACTIVE_TARGET_SELECTOR =
'a, button, input, textarea, select, video, audio, [role="button"], [contenteditable="true"]';
const mobileLongPressScrollCancelHandlers = new Set<() => void>();
let mobileLongPressScrollListenerAttached = false;
@@ -161,6 +168,8 @@ const getElementFromEventTarget = (target: EventTarget | null): Element | null =
};
const isNestedLongPressOwnerTarget = (target: EventTarget | null): boolean =>
getElementFromEventTarget(target)?.closest(NESTED_LONG_PRESS_OWNER_SELECTOR) != null;
const isDoubleClickInteractiveTarget = (target: EventTarget | null): boolean =>
getElementFromEventTarget(target)?.closest(DOUBLE_CLICK_INTERACTIVE_TARGET_SELECTOR) != null;
const normalizeAriaLabelText = (text: string): string => text.replace(/\s+/g, ' ').trim();
const truncateAriaLabelText = (text: string): string => {
const normalized = normalizeAriaLabelText(text);
@@ -180,6 +189,7 @@ export type MessageBehaviorOverrides = Partial<{
contextMenuOpen: boolean;
disableContextMenu: boolean;
disableContextMenuTracking: boolean;
disableDoubleClick: boolean;
}>;
interface MessageProps {
@@ -305,6 +315,38 @@ export const Message: React.FC<MessageProps> = observer((props) => {
},
[message],
);
const handleDoubleClick = useCallback(
(event: React.MouseEvent) => {
if (
behaviorOverrides?.disableDoubleClick ||
!Accessibility.doubleClickMessageActions ||
mobileLayoutEnabled ||
(previewContext && previewContext !== MessagePreviewContext.LIST_POPOUT) ||
message.state !== MessageStates.SENT ||
!message.isUserMessage() ||
isEditing ||
isDoubleClickInteractiveTarget(event.target)
) {
return;
}
const permissions = getMessagePermissions(message, channel);
if (!permissions?.canSendMessages) return;
if (permissions.canEditMessage && !message.messageSnapshots) {
startMessageEdit(message);
return;
}
if (!isReplying) requestMessageReply(message, {sourceChannel: channel});
},
[
previewContext,
message,
channel,
isEditing,
isReplying,
mobileLayoutEnabled,
behaviorOverrides?.disableDoubleClick,
],
);
const handleAltKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
handleAltKeyboardEvent(event, message);
@@ -702,6 +744,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
ref={messageRef}
onClickCapture={handleClickCapture}
onClick={handleAltClick}
onDoubleClick={handleDoubleClick}
onKeyDown={handleAltKeyDown}
onFocus={handleFocusWithin}
onBlur={handleBlurWithin}
@@ -155,7 +155,7 @@
background: none;
border: none;
padding: 0 0.75rem;
color: white;
color: var(--brand-primary-fill);
font-size: 0.875rem;
font-weight: 500;
overflow: hidden;
@@ -172,7 +172,7 @@
background: none;
border: none;
padding: 0 0.75rem;
color: white;
color: var(--brand-primary-fill);
font-size: 0.875rem;
font-weight: 600;
flex-shrink: 0;
@@ -270,6 +270,16 @@
text-shadow: 0 0.0625rem 0.0625rem rgba(0, 0, 0, 0.06);
}
.gridItemCategoryEmpty .gridItemIcon,
.gridItemCategoryEmpty .gridItemCategoryTitleText {
color: var(--text-on-brand-primary-light);
}
.gridItemFavorites .gridItemIcon,
.gridItemFavorites .gridItemCategoryTitleText {
color: var(--text-on-brand-primary);
}
.gif {
width: 100%;
height: 100%;
@@ -445,6 +455,8 @@
color: var(--text-primary);
}
:global(.theme-light) .gridItemCategoryEmpty .gridItemCategoryTitleText,
:global(.theme-light) .gridItemCategoryEmpty .gridItemIcon,
:global(.theme-light) .gridItemFavorites .gridItemCategoryTitleText,
:global(.theme-light) .gridItemFavorites .gridItemIcon {
color: var(--text-on-brand-primary);
@@ -459,8 +471,8 @@
}
:global(.theme-light) .gridItemFocused {
border-color: var(--brand-primary);
box-shadow: inset 0 0 0 0.125rem var(--brand-primary);
border-color: var(--brand-primary-foreground);
box-shadow: inset 0 0 0 0.125rem var(--brand-primary-foreground);
}
:global(.theme-light) .gridItemFavorites .gridItemBackdrop {
@@ -195,7 +195,7 @@
}
:global(.theme-light) .progressBar {
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
}
.thContent {
@@ -138,7 +138,7 @@
.audioIcon {
height: 3rem;
width: 3rem;
color: white;
color: var(--brand-primary-fill);
flex-shrink: 0;
}
@@ -153,7 +153,7 @@
.audioDuration {
font-family: var(--font-mono);
font-size: 1.125rem;
color: white;
color: var(--brand-primary-fill);
}
.audioFilename {
@@ -164,14 +164,14 @@
text-align: center;
font-weight: 600;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.9);
color: color-mix(in srgb, var(--brand-primary-fill) 90%, transparent);
}
.audioBadge {
padding: 0.125rem 0.5rem;
border-radius: 0.375rem;
background-color: rgba(0, 0, 0, 0.2);
background-color: color-mix(in srgb, var(--brand-primary-shade) 20%, transparent);
font-weight: 700;
font-size: 0.75rem;
color: white;
color: var(--brand-primary-fill);
}
@@ -76,6 +76,7 @@ const MessageGroupBase: React.FC<MessageGroupProps> = observer((props) => {
? {
...providedBehaviorOverrides,
disableContextMenu: true,
disableDoubleClick: true,
}
: providedBehaviorOverrides,
[suppressMessageActions, providedBehaviorOverrides],
@@ -36,16 +36,16 @@
}
.reactionMe .reactionButton {
background-color: color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%);
border-color: var(--brand-primary);
color: var(--text-on-brand-primary);
background-color: var(--brand-tint);
border-color: var(--brand-tint-border);
color: var(--text-on-brand-tint);
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.reactionMe
.reactionButton:hover {
background-color: color-mix(in srgb, var(--brand-primary) 48%, var(--background-secondary) 52%);
border-color: var(--brand-primary);
background-color: var(--brand-tint-hover);
border-color: var(--brand-tint-border);
}
.reactionInner {
@@ -80,7 +80,7 @@
}
.reactionMe .countWrapper {
color: var(--text-on-brand-primary);
color: var(--text-on-brand-tint);
}
:global(.theme-light) .reactionButton {
@@ -96,20 +96,20 @@
}
:global(.theme-light) .reactionMe .reactionButton {
background-color: color-mix(in srgb, var(--brand-primary) 12%, var(--background-primary) 88%);
border-color: color-mix(in srgb, var(--brand-primary) 45%, transparent 55%);
color: var(--brand-primary);
background-color: var(--brand-tint);
border-color: var(--brand-tint-border);
color: var(--text-on-brand-tint);
}
:global(.theme-light:where(.window-focused, .unfocused-fully-interactive):not(.window-focus-activation-guard))
.reactionMe
.reactionButton:hover {
background-color: color-mix(in srgb, var(--brand-primary) 18%, var(--background-primary) 82%);
border-color: var(--brand-primary);
background-color: var(--brand-tint-hover);
border-color: var(--brand-primary-foreground);
}
:global(.theme-light) .reactionMe .countWrapper {
color: var(--brand-primary);
color: var(--text-on-brand-tint);
}
.reactionContainerDisabled {
@@ -127,8 +127,8 @@
.reactionMe .reactionButtonDisabled,
.reactionMe .reactionButtonDisabled:hover {
background-color: color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%);
border-color: var(--brand-primary);
background-color: var(--brand-tint);
border-color: var(--brand-tint-border);
}
:global(.theme-light) .reactionButtonDisabled,
@@ -139,8 +139,8 @@
:global(.theme-light) .reactionMe .reactionButtonDisabled,
:global(.theme-light) .reactionMe .reactionButtonDisabled:hover {
background-color: color-mix(in srgb, var(--brand-primary) 12%, var(--background-primary) 88%);
border-color: color-mix(in srgb, var(--brand-primary) 45%, transparent 55%);
background-color: var(--brand-tint);
border-color: var(--brand-tint-border);
}
.reactionPickerButton {
@@ -110,5 +110,5 @@
:global(.theme-light) .progressBarIndeterminate,
:global(.theme-light) .progressBarNormal {
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
}
@@ -170,7 +170,7 @@
}
:global(.theme-light) .tabButtonActive {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.tabButtonInactive {
@@ -367,7 +367,7 @@
}
.newGroupIconWhite {
color: white;
color: var(--brand-primary-fill);
}
.newGroupContent {
@@ -555,12 +555,13 @@
.quickActionButtonActive {
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
@media (hover: hover) and (pointer: fine) {
.quickActionButtonActive:hover {
background-color: var(--brand-primary-light);
color: var(--brand-primary-light-fill);
}
}
@@ -35,7 +35,7 @@
}
:global(.theme-light) .heroIcon {
color: var(--brand-primary);
color: var(--brand-primary-foreground);
}
.title {
@@ -90,7 +90,7 @@
:global(.theme-light) .dmListHeaderShortcut kbd {
background-color: hsl(220, calc(13% * var(--saturation-factor)), 18.44%);
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
box-shadow: 0 0.0625rem 0 rgba(0, 0, 0, 0.4);
}
@@ -280,7 +280,7 @@
.clickableItemSelected .clickableItemIcon {
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
@media (hover: hover) and (pointer: fine) {
@@ -313,7 +313,7 @@
.sendIcon {
height: 1.5rem;
width: 1.5rem;
color: white;
color: var(--brand-primary-fill);
}
.clickableItemText {
@@ -725,7 +725,7 @@
.mobileSpecialButtonSelected .mobileSpecialButtonIcon {
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.mobileSpecialButtonText {
@@ -403,13 +403,17 @@
.languageItemSelected {
background-color: var(--brand-primary);
color: #fff;
color: var(--brand-primary-fill);
}
.languageItemSelected.languageItemHighlighted {
background-color: var(--brand-primary);
}
.languageItemSelected .languageOptionCanonical {
color: inherit;
}
.languageItemIndicator {
position: absolute;
top: 50%;
@@ -322,7 +322,7 @@ export const collectGalleryImages = ({
};
export const getBorderColor = (color: number | undefined) => {
if (color === undefined || color === 0) {
return 'var(--brand-primary)';
return 'var(--brand-primary-foreground)';
}
return ColorUtils.int2rgb(color);
};
@@ -149,7 +149,7 @@
border: none;
border-radius: var(--radius-full);
background-color: rgba(0, 0, 0, 0.6);
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
cursor: pointer;
z-index: 2;
transition: background-color 150ms ease;
@@ -14,5 +14,5 @@
font-weight: 600;
font-size: 0.875rem;
line-height: 1;
color: var(--text-on-brand-primary);
color: var(--text-on-dark);
}
@@ -10,7 +10,7 @@
--action-toolbar-item-size: 2rem;
--action-toolbar-item-border-width: 0px;
--action-toolbar-item-background: rgba(0, 0, 0, 0.75);
--action-toolbar-item-color: var(--text-on-brand-primary);
--action-toolbar-item-color: var(--text-on-dark);
}
.overlayButton svg {
@@ -25,5 +25,5 @@
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.overlayButton.overlayButtonHover:hover
svg {
color: color-mix(in srgb, var(--text-on-brand-primary) 95%, transparent);
color: color-mix(in srgb, var(--text-on-dark) 95%, transparent);
}
@@ -46,11 +46,15 @@
inset: -0.1875rem;
border-radius: var(--radius-full);
border: 0.125rem solid transparent;
border-top-color: var(--text-on-brand-primary);
border-top-color: var(--brand-primary-foreground);
animation: spin 0.8s linear infinite;
pointer-events: none;
}
.containerActive .loadingSpinner {
border-top-color: var(--text-on-brand-primary);
}
@keyframes spin {
from {
transform: rotate(0deg);
@@ -84,7 +88,7 @@
}
:global(.theme-light) .waveformBarPast {
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
}
.timestamp {
@@ -211,13 +211,13 @@
.actionButtonAccept {
--action-toolbar-item-background: var(--friend-action-brand-background);
--action-toolbar-item-color: color-mix(in srgb, var(--brand-primary) 76%, var(--text-primary) 24%);
--action-toolbar-item-color: color-mix(in srgb, var(--brand-primary-foreground) 76%, var(--text-primary) 24%);
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.actionButtonAccept:hover {
--action-toolbar-item-background: var(--friend-action-brand-hover-background);
--action-toolbar-item-color: color-mix(in srgb, var(--brand-primary) 82%, var(--text-primary) 18%);
--action-toolbar-item-color: color-mix(in srgb, var(--brand-primary-foreground) 82%, var(--text-primary) 18%);
}
.actionButtonIgnore {
@@ -41,5 +41,5 @@
}
:global(.theme-light) .progressBar {
background-color: var(--brand-primary);
background-color: var(--brand-primary-foreground);
}
@@ -114,7 +114,7 @@
.actionButton:hover {
border-color: var(--brand-primary);
background-color: var(--brand-primary);
color: white;
color: var(--brand-primary-fill);
}
.deleteButton:hover {
Loaded 100 of 327 files, more files were not shown because too many files have changed in this diff. Show more