mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
chore(repo): remove dead code and useless tests (#3375)
This commit is contained in:
1 parent
7e177a1c90
commit
1cd62eb661
1391 files changed
+783
-120415
No files matched your search
@@ -10,7 +10,7 @@ use axum::{
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
config::{AdminConfig, ProxyConfig},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -85,7 +85,6 @@ async fn setup() -> TestApp {
|
||||
|
||||
fn production_config(api_endpoint: String) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Production,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,7 +11,7 @@ use axum::{
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
config::{AdminConfig, ProxyConfig},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -346,7 +346,6 @@ fn admin_user() -> Value {
|
||||
|
||||
fn test_config(api_endpoint: String) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
|
||||
@@ -1,635 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[path = "parity/mod.rs"]
|
||||
mod parity_support;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, RawQuery},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use fluxer_admin::api::{
|
||||
generated::types::{
|
||||
AdminReportListResponse, LookupGuildResponse, ReportAdminResponseSchema,
|
||||
SearchGuildsResponse,
|
||||
},
|
||||
types::{ReportEntry, SearchReportsResponse},
|
||||
};
|
||||
use parity_support::{
|
||||
TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture,
|
||||
html_normalizer, rust_server,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{error::Error, io};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const SEARCH_REPORTS_V2: &str = include_str!("parity/fixtures/api/search_reports_v2.json");
|
||||
const SEARCH_REPORTS_WEBHOOK: &str =
|
||||
include_str!("parity/fixtures/api/search_reports_webhook.json");
|
||||
const REPORT_WEBHOOK_DETAIL: &str = include_str!("parity/fixtures/api/report_webhook_detail.json");
|
||||
const REPORT_EVIDENCE_DETAIL: &str =
|
||||
include_str!("parity/fixtures/api/report_evidence_detail.json");
|
||||
const EVIDENCE_REPORT_ID: &str = "1556352159220498613";
|
||||
const EVIDENCE_BOT_ID: &str = "1556352159149195428";
|
||||
const EVIDENCE_WEBHOOK_ID: &str = "1556352159132418208";
|
||||
const EVIDENCE_DELETED_AUTHOR_ID: &str = "1556352159216304308";
|
||||
const WEBHOOK_ID: &str = "1556114449176200401";
|
||||
const WEBHOOK_REPORT_ID: &str = "1556114449264280806";
|
||||
const BOT_REPORT_ID: &str = "1556114449268475111";
|
||||
const BOT_USER_ID: &str = "1556114449192977621";
|
||||
const WEBHOOK_CREATOR_ID: &str = "1556114449125868741";
|
||||
|
||||
#[test]
|
||||
fn html_normalizer_canonicalizes_attribute_order_and_csrf_values() {
|
||||
let left = r#"<form><input value="aaaaaaaa" name="_csrf" type="hidden"><svg><line x1="1" x2="2"></line></svg><a class="b" href="/static/app.css?v=123" id="x">Open</a></form>"#;
|
||||
let right = r#"<form><input type="hidden" name="_csrf" value="bbbbbbbb"/><svg><line x2="2" x1="1"/></svg><a id="x" href="/static/app.css?v=456" class="b">Open</a></form>"#;
|
||||
assert_eq!(
|
||||
html_normalizer::normalize_html(left),
|
||||
html_normalizer::normalize_html(right)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_normalizer_replaces_ports_assets_query_values_and_cookie_tokens() {
|
||||
let raw = "http://127.0.0.1:31987/auth/start?state=abc&next=/static/app.css?v=dev admin_session=abcdef; csrf_token=12345; oauth_state=zz";
|
||||
let normalized = html_normalizer::normalize_text(raw);
|
||||
assert!(normalized.contains("127.0.0.1:__PORT__"));
|
||||
assert!(normalized.contains("state=__OAUTH_STATE__"));
|
||||
assert!(normalized.contains("/static/app.css"));
|
||||
assert!(normalized.contains("admin_session=__SESSION__"));
|
||||
assert!(normalized.contains("csrf_token=__CSRF_COOKIE__"));
|
||||
assert!(normalized.contains("oauth_state=__OAUTH_STATE__"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_normalizer_replaces_script_csrf_values() {
|
||||
let raw = r#"<script>(function(){var csrf="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";})()</script>"#;
|
||||
let normalized = html_normalizer::normalize_text(raw);
|
||||
assert!(normalized.contains(r#"var csrf="__CSRF_TOKEN__""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_normalizer_allows_intentional_rust_markup_fixes() {
|
||||
let ts = r#"<html><head></head><body><div class="flex flex-col gap-8 items-stretch"></div><div class="flex flex-col gap-4 items-stretch"></div><script defer>window.__fluxerDrawerInit = true;</script><aside data-drawer-panel="user-peek" aria-hidden="true"></aside></body></html>"#;
|
||||
let rust = r#"<!DOCTYPE html><html><head><script src="/static/htmx.min.js?t=parity" defer></script></head><body hx-boost="true"><div class="flex flex-col gap-8 items-center"></div><div id="users-results" class="flex flex-col gap-4 items-stretch"></div><script defer>document.body.addEventListener('showFlash', function () {});</script><script defer>window.__adminCopyToClipboard = function () {};</script><aside id="user-peek" data-drawer-panel="user-peek" popover="auto"></aside></body></html>"#;
|
||||
assert_eq!(
|
||||
html_normalizer::normalize_html(ts),
|
||||
html_normalizer::normalize_html(rust)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_search_fixture_matches_the_generated_response_contract() {
|
||||
let response: SearchGuildsResponse =
|
||||
serde_json::from_str(include_str!("parity/fixtures/api/search_guilds.json"))
|
||||
.expect("guild search fixture must match the generated response contract");
|
||||
assert_eq!(response.guilds.len(), 1);
|
||||
let guild = &response.guilds[0];
|
||||
assert_eq!(guild.name, "Parity Guild");
|
||||
assert_eq!(guild.nsfw, Some(false));
|
||||
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_lookup_fixture_matches_the_generated_response_contract() {
|
||||
let response: LookupGuildResponse =
|
||||
serde_json::from_str(include_str!("parity/fixtures/api/lookup_guild.json"))
|
||||
.expect("guild lookup fixture must match the generated response contract");
|
||||
let guild = response
|
||||
.guild
|
||||
.expect("guild lookup fixture must contain a guild");
|
||||
assert_eq!(String::from(guild.name), "Parity Guild");
|
||||
assert_eq!(guild.nsfw, Some(false));
|
||||
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
|
||||
assert_eq!(guild.channels.len(), 1);
|
||||
assert_eq!(guild.channels[0].content_warning_level.as_deref(), Some(&0));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<(), Box<dyn Error>> {
|
||||
let api_server = api_fixtures::ApiFixtureServer::start_default()
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let rust_admin = rust_server::start(api_server.base_url())
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
let session = fluxer_admin::session::create_session(
|
||||
TEST_ADMIN_USER_ID,
|
||||
TEST_ACCESS_TOKEN,
|
||||
TEST_ADMIN_SECRET,
|
||||
);
|
||||
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
|
||||
let cases = [
|
||||
("/dashboard", 302, Some("/users"), None),
|
||||
("/users?q=Parity", 200, None, Some("Parity User")),
|
||||
("/guilds?q=Parity", 200, None, Some("Parity Guild")),
|
||||
(
|
||||
"/guilds/1600000000000000001",
|
||||
200,
|
||||
None,
|
||||
Some("Parity Guild"),
|
||||
),
|
||||
("/reports", 200, None, Some("1700000000000000001")),
|
||||
(
|
||||
"/reports/1700000000000000001",
|
||||
200,
|
||||
None,
|
||||
Some("Report Details"),
|
||||
),
|
||||
];
|
||||
for (route, expected_status, expected_location, expected_body) in cases {
|
||||
let response =
|
||||
capture::fetch_route(&client, rust_admin.base_url(), route, Some(&session_cookie))
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(response.status, expected_status, "{route}: {response:#?}");
|
||||
if let Some(expected_location) = expected_location {
|
||||
assert_eq!(
|
||||
response.location.as_deref(),
|
||||
Some(expected_location),
|
||||
"{route}: {response:#?}"
|
||||
);
|
||||
}
|
||||
if let Some(expected_body) = expected_body {
|
||||
assert!(
|
||||
response.body.contains(expected_body),
|
||||
"{route}: expected body to contain {expected_body:?}\n{response:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_search_v2_fixture_matches_the_generated_and_hand_written_contracts() {
|
||||
let generated: AdminReportListResponse = serde_json::from_str(SEARCH_REPORTS_V2)
|
||||
.expect("v2 report search fixture must match the generated response contract");
|
||||
let flow = generated.reports[0]
|
||||
.flow
|
||||
.as_ref()
|
||||
.expect("the v2 message report has answers");
|
||||
assert_eq!(flow.surface, "in_app");
|
||||
assert_eq!(flow.steps.len(), 4);
|
||||
assert_eq!(generated.reports[0].reason.as_deref(), Some("csam"));
|
||||
assert_eq!(generated.reports[2].reason, None);
|
||||
|
||||
let response: SearchReportsResponse = serde_json::from_str(SEARCH_REPORTS_V2)
|
||||
.expect("v2 report search fixture must match the hand-written response type");
|
||||
assert_eq!(response.total, 3);
|
||||
let [message, user, legacy] = response.reports.as_slice() else {
|
||||
panic!("expected three reports");
|
||||
};
|
||||
assert_eq!(
|
||||
message.reason_label.as_deref(),
|
||||
Some("Child sexual abuse material")
|
||||
);
|
||||
assert_eq!(message.reason_highest_priority, Some(true));
|
||||
assert_eq!(message.category.as_deref(), Some("child_safety"));
|
||||
let user_flow = user.flow.as_ref().expect("the v2 user report has answers");
|
||||
assert_eq!(user.reason.as_deref(), Some("harassment"));
|
||||
assert_eq!(user_flow.locale.as_deref(), Some("de"));
|
||||
assert_eq!(user_flow.steps[0].screen_id, "profile_intro");
|
||||
assert!(user_flow.steps[0].option_id.is_none());
|
||||
assert!(user_flow.steps[0].items.is_empty());
|
||||
assert_eq!(
|
||||
user_flow.steps[1]
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["photo", "profile_text"]
|
||||
);
|
||||
assert!(legacy.reason.is_none());
|
||||
assert!(legacy.reason_label.is_none());
|
||||
assert!(legacy.flow.is_none());
|
||||
assert!(legacy.reporter_good_faith_confirmed.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_admin_renders_v2_report_fixture_with_reason_and_answers() -> Result<(), Box<dyn Error>>
|
||||
{
|
||||
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
|
||||
let rust_admin = rust_server::start(&api_endpoint)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
let session = fluxer_admin::session::create_session(
|
||||
TEST_ADMIN_USER_ID,
|
||||
TEST_ACCESS_TOKEN,
|
||||
TEST_ADMIN_SECRET,
|
||||
);
|
||||
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
|
||||
|
||||
let list = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
"/reports?reason=csam",
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(list.status, 200, "{list:#?}");
|
||||
assert!(list.body.contains(r#"name="reason""#), "{}", list.body);
|
||||
assert!(
|
||||
list.body.contains("Priority: Child sexual abuse material"),
|
||||
"{}",
|
||||
list.body
|
||||
);
|
||||
assert!(
|
||||
list.body.contains(r#"data-report-reason="csam""#),
|
||||
"{}",
|
||||
list.body
|
||||
);
|
||||
assert!(
|
||||
!list.body.contains(r#"data-report-reason="harassment""#),
|
||||
"{}",
|
||||
list.body
|
||||
);
|
||||
|
||||
let detail = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
"/reports/1556008115705480394",
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(detail.status, 200, "{detail:#?}");
|
||||
for expected in [
|
||||
"Report Answers",
|
||||
"Report profile",
|
||||
"Which parts of their profile are a problem?: Pictures, Profile text",
|
||||
"Shown to the reporter in de",
|
||||
"Surface: In app",
|
||||
"Harassment or bullying",
|
||||
] {
|
||||
assert!(
|
||||
detail.body.contains(expected),
|
||||
"{expected}\n{}",
|
||||
detail.body
|
||||
);
|
||||
}
|
||||
|
||||
let legacy = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
"/reports/1556008115709674699",
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(legacy.status, 200, "{legacy:#?}");
|
||||
assert!(!legacy.body.contains("Report Answers"), "{}", legacy.body);
|
||||
assert!(
|
||||
!legacy.body.contains("data-report-reason"),
|
||||
"{}",
|
||||
legacy.body
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_webhook_fixtures_match_the_generated_and_hand_written_contracts() {
|
||||
let generated: AdminReportListResponse = serde_json::from_str(SEARCH_REPORTS_WEBHOOK)
|
||||
.expect("webhook report search fixture must match the generated response contract");
|
||||
assert_eq!(generated.reports.len(), 2);
|
||||
let response: SearchReportsResponse = serde_json::from_str(SEARCH_REPORTS_WEBHOOK)
|
||||
.expect("webhook report search fixture must match the hand-written response type");
|
||||
let [webhook, bot] = response.reports.as_slice() else {
|
||||
panic!("expected two reports");
|
||||
};
|
||||
assert_eq!(webhook.report_id, WEBHOOK_REPORT_ID);
|
||||
assert!(webhook.reported_user_id.is_none());
|
||||
assert_eq!(webhook.reported_webhook_id.as_deref(), Some(WEBHOOK_ID));
|
||||
assert_eq!(
|
||||
webhook.reported_webhook_name.as_deref(),
|
||||
Some("Harbor Bulletin")
|
||||
);
|
||||
assert_eq!(bot.reported_user_id.as_deref(), Some(BOT_USER_ID));
|
||||
assert!(bot.reported_webhook_id.is_none());
|
||||
|
||||
let generated_detail: ReportAdminResponseSchema = serde_json::from_str(REPORT_WEBHOOK_DETAIL)
|
||||
.expect("webhook report detail fixture must match the generated response contract");
|
||||
let via_generated: ReportEntry = serde_json::from_value(
|
||||
serde_json::to_value(generated_detail).expect("serialize generated detail"),
|
||||
)
|
||||
.expect("hand-written report type from the generated detail");
|
||||
let direct: ReportEntry = serde_json::from_str(REPORT_WEBHOOK_DETAIL)
|
||||
.expect("webhook report detail fixture must match the hand-written report type");
|
||||
for detail in [&via_generated, &direct] {
|
||||
assert!(detail.reported_user_id.is_none());
|
||||
assert_eq!(detail.reported_webhook_id.as_deref(), Some(WEBHOOK_ID));
|
||||
assert_eq!(
|
||||
detail.reported_webhook_creator_id.as_deref(),
|
||||
Some(WEBHOOK_CREATOR_ID)
|
||||
);
|
||||
assert_eq!(
|
||||
detail.reported_webhook_creator_global_name.as_deref(),
|
||||
Some("Morgan Owner")
|
||||
);
|
||||
assert_eq!(detail.reported_webhook_type, Some(1));
|
||||
assert_eq!(
|
||||
detail.reported_webhook_channel_id,
|
||||
detail.reported_channel_id
|
||||
);
|
||||
assert_eq!(detail.reported_webhook_guild_id, detail.reported_guild_id);
|
||||
assert_eq!(
|
||||
detail.reported_webhook_created_at.as_deref(),
|
||||
Some("2026-10-04T01:23:15.892Z")
|
||||
);
|
||||
assert!(detail.reported_webhook_application_id.is_none());
|
||||
let context = detail.message_context.as_ref().expect("message context");
|
||||
let entry = context
|
||||
.iter()
|
||||
.find(|entry| entry["webhook_id"].as_str() == Some(WEBHOOK_ID))
|
||||
.expect("the webhook message is in the context");
|
||||
assert_eq!(entry["author_id"].as_str(), Some(WEBHOOK_ID));
|
||||
assert_eq!(entry["author_username"].as_str(), Some("Harbor Bulletin"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_admin_renders_webhook_report_fixture() -> Result<(), Box<dyn Error>> {
|
||||
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
|
||||
let rust_admin = rust_server::start(&api_endpoint)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
let session = fluxer_admin::session::create_session(
|
||||
TEST_ADMIN_USER_ID,
|
||||
TEST_ACCESS_TOKEN,
|
||||
TEST_ADMIN_SECRET,
|
||||
);
|
||||
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
|
||||
let webhook_marker = format!(r#"data-report-webhook="{WEBHOOK_ID}""#);
|
||||
let webhook_user_link = format!("/users/{WEBHOOK_ID}");
|
||||
|
||||
let list = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
&format!("/reports?reported_webhook_id={WEBHOOK_ID}"),
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(list.status, 200, "{list:#?}");
|
||||
assert!(list.body.contains(WEBHOOK_REPORT_ID), "{}", list.body);
|
||||
assert!(!list.body.contains(BOT_REPORT_ID), "{}", list.body);
|
||||
assert!(list.body.contains(&webhook_marker), "{}", list.body);
|
||||
assert!(list.body.contains("Harbor Bulletin"), "{}", list.body);
|
||||
assert!(list.body.contains("Channel: general"), "{}", list.body);
|
||||
assert!(!list.body.contains(&webhook_user_link), "{}", list.body);
|
||||
|
||||
let both = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
"/reports?reporter_id=1556114449113285826",
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(both.status, 200, "{both:#?}");
|
||||
assert!(
|
||||
both.body
|
||||
.contains(&format!(r#"href="/users/{BOT_USER_ID}""#)),
|
||||
"{}",
|
||||
both.body
|
||||
);
|
||||
|
||||
let detail = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
&format!("/reports/{WEBHOOK_REPORT_ID}"),
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(detail.status, 200, "{detail:#?}");
|
||||
for expected in [
|
||||
webhook_marker.clone(),
|
||||
">Webhook ID<".to_owned(),
|
||||
format!(r#"href="/reports?reported_webhook_id={WEBHOOK_ID}""#),
|
||||
"Harbor Commons".to_owned(),
|
||||
"Message Context".to_owned(),
|
||||
format!(r#"data-message-webhook="{WEBHOOK_ID}""#),
|
||||
format!(r#"href="/users/{BOT_USER_ID}""#),
|
||||
">Webhook Creator<".to_owned(),
|
||||
format!(r#"data-report-webhook-creator="{WEBHOOK_CREATOR_ID}""#),
|
||||
format!(r#"href="/users/{WEBHOOK_CREATOR_ID}""#),
|
||||
"Morgan Owner".to_owned(),
|
||||
">Webhook Type<".to_owned(),
|
||||
">Incoming<".to_owned(),
|
||||
">Webhook Created<".to_owned(),
|
||||
"Oct 4, 2026, 1:23 AM UTC".to_owned(),
|
||||
] {
|
||||
assert!(
|
||||
detail.body.contains(&expected),
|
||||
"{expected}\n{}",
|
||||
detail.body
|
||||
);
|
||||
}
|
||||
assert!(!detail.body.contains(&webhook_user_link), "{}", detail.body);
|
||||
assert!(
|
||||
!detail.body.contains("View Reported User"),
|
||||
"{}",
|
||||
detail.body
|
||||
);
|
||||
for absent in [
|
||||
">Webhook Channel ID<",
|
||||
">Webhook Guild ID<",
|
||||
">Webhook Record<",
|
||||
"data-report-webhook-creator-deleted",
|
||||
] {
|
||||
assert!(!detail.body.contains(absent), "{absent}\n{}", detail.body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_evidence_fixture_matches_the_generated_and_hand_written_contracts() {
|
||||
let generated: ReportAdminResponseSchema = serde_json::from_str(REPORT_EVIDENCE_DETAIL)
|
||||
.expect("evidence report detail fixture must match the generated response contract");
|
||||
let via_generated: ReportEntry = serde_json::from_value(
|
||||
serde_json::to_value(generated).expect("serialize generated detail"),
|
||||
)
|
||||
.expect("hand-written report type from the generated detail");
|
||||
let direct: ReportEntry = serde_json::from_str(REPORT_EVIDENCE_DETAIL)
|
||||
.expect("evidence report detail fixture must match the hand-written report type");
|
||||
for detail in [&via_generated, &direct] {
|
||||
assert_eq!(detail.reported_user_id.as_deref(), Some(EVIDENCE_BOT_ID));
|
||||
assert_eq!(detail.reported_user_bot, Some(true));
|
||||
let snapshot = detail
|
||||
.reported_profile_snapshot
|
||||
.as_ref()
|
||||
.expect("the bot author has a profile snapshot");
|
||||
let user = snapshot.user.as_ref().expect("user snapshot");
|
||||
assert_eq!(user.id, EVIDENCE_BOT_ID);
|
||||
assert_eq!(user.username.as_deref(), Some("Harbor_Helper"));
|
||||
let member = snapshot.member.as_ref().expect("member snapshot");
|
||||
assert_eq!(member.guild_id, "1556352159090475158");
|
||||
assert!(member.joined_at.is_some());
|
||||
assert!(snapshot.guild.is_none());
|
||||
let context = detail.message_context.as_ref().expect("message context");
|
||||
let author_bot = |author_id: &str| {
|
||||
context
|
||||
.iter()
|
||||
.filter(|entry| entry["author_id"].as_str() == Some(author_id))
|
||||
.map(|entry| entry["author_bot"].clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(author_bot(EVIDENCE_BOT_ID), [json!(true), json!(true)]);
|
||||
assert_eq!(author_bot(EVIDENCE_WEBHOOK_ID), [Value::Null]);
|
||||
assert_eq!(author_bot(EVIDENCE_DELETED_AUTHOR_ID), [Value::Null]);
|
||||
assert_eq!(author_bot("1556352159069503633"), [json!(false)]);
|
||||
assert!(context.iter().all(|entry| {
|
||||
entry["missing_attachments"]
|
||||
.as_array()
|
||||
.is_none_or(Vec::is_empty)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_admin_renders_the_evidence_report_fixture() -> Result<(), Box<dyn Error>> {
|
||||
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
|
||||
let rust_admin = rust_server::start(&api_endpoint)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
let session = fluxer_admin::session::create_session(
|
||||
TEST_ADMIN_USER_ID,
|
||||
TEST_ACCESS_TOKEN,
|
||||
TEST_ADMIN_SECRET,
|
||||
);
|
||||
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
|
||||
let detail = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
&format!("/reports/{EVIDENCE_REPORT_ID}"),
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(detail.status, 200, "{detail:#?}");
|
||||
for expected in [
|
||||
format!(r#"data-report-user-bot="{EVIDENCE_BOT_ID}""#),
|
||||
format!(r#"data-message-author-bot="{EVIDENCE_BOT_ID}""#),
|
||||
format!(r#"data-message-webhook="{EVIDENCE_WEBHOOK_ID}""#),
|
||||
"At Report Time".to_owned(),
|
||||
"Harbor_Helper#3966".to_owned(),
|
||||
">Community Profile<".to_owned(),
|
||||
r#"data-report-legal-hold="none""#.to_owned(),
|
||||
] {
|
||||
assert!(
|
||||
detail.body.contains(&expected),
|
||||
"{expected}\n{}",
|
||||
detail.body
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
detail.body.matches("data-message-author-bot=").count(),
|
||||
1,
|
||||
"{}",
|
||||
detail.body
|
||||
);
|
||||
assert!(
|
||||
!detail.body.contains("data-missing-attachment"),
|
||||
"{}",
|
||||
detail.body
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn spawn_v2_report_api() -> Result<String, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|error| format!("failed to bind v2 report API: {error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("failed to read v2 report API address: {error}"))?
|
||||
.port();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/admin/users/@me",
|
||||
get(|| async {
|
||||
fixture_json(include_str!("parity/fixtures/api/admin_user_me.json"))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/admin/report-reasons",
|
||||
get(|| async {
|
||||
Json(json!({"reasons": [
|
||||
{"key": "csam", "label": "Child sexual abuse material", "highest_priority": true, "legacy_category_message": "child_safety", "legacy_category_user": "child_safety", "legacy_category_guild": "child_safety"},
|
||||
{"key": "harassment", "label": "Harassment or bullying", "highest_priority": false, "legacy_category_message": "harassment", "legacy_category_user": "harassment", "legacy_category_guild": "harassment"}
|
||||
]}))
|
||||
.into_response()
|
||||
}),
|
||||
)
|
||||
.route("/admin/reports", get(v2_report_search))
|
||||
.route("/admin/reports/{report_id}", get(v2_report_detail));
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
Ok(format!("http://127.0.0.1:{port}"))
|
||||
}
|
||||
|
||||
fn fixture_json(body: &'static str) -> Response {
|
||||
let value: Value = serde_json::from_str(body).expect("fixture is JSON");
|
||||
Json(value).into_response()
|
||||
}
|
||||
|
||||
fn v2_reports() -> Vec<Value> {
|
||||
[SEARCH_REPORTS_V2, SEARCH_REPORTS_WEBHOOK]
|
||||
.into_iter()
|
||||
.flat_map(|body| {
|
||||
let value: Value = serde_json::from_str(body).expect("fixture is JSON");
|
||||
value["reports"].as_array().cloned().unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn v2_report_search(RawQuery(query): RawQuery) -> Response {
|
||||
let query = query.unwrap_or_default();
|
||||
let filters = url::form_urlencoded::parse(query.as_bytes())
|
||||
.filter(|(key, value)| {
|
||||
matches!(
|
||||
key.as_ref(),
|
||||
"reason" | "reporter_id" | "reported_webhook_id"
|
||||
) && !value.is_empty()
|
||||
})
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
let reports = v2_reports()
|
||||
.into_iter()
|
||||
.filter(|report| {
|
||||
filters
|
||||
.iter()
|
||||
.all(|(key, value)| report[key.as_str()].as_str() == Some(value.as_str()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Json(json!({"reports": reports, "total": reports.len(), "offset": 0, "limit": 25}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn v2_report_detail(Path(report_id): Path<String>) -> Response {
|
||||
if report_id == WEBHOOK_REPORT_ID {
|
||||
return fixture_json(REPORT_WEBHOOK_DETAIL);
|
||||
}
|
||||
if report_id == EVIDENCE_REPORT_ID {
|
||||
return fixture_json(REPORT_EVIDENCE_DETAIL);
|
||||
}
|
||||
match v2_reports()
|
||||
.into_iter()
|
||||
.find(|report| report["report_id"].as_str() == Some(report_id.as_str()))
|
||||
{
|
||||
Some(report) => Json(report).into_response(),
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_error(message: String) -> Box<dyn Error> {
|
||||
Box::new(io::Error::other(message))
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{HeaderName, HeaderValue, Request, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing::any,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::{net::TcpListener, task::JoinHandle};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ApiFixtureRoute {
|
||||
method: String,
|
||||
path: String,
|
||||
status: u16,
|
||||
content_type: String,
|
||||
headers: HashMap<String, String>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ApiFixtureSet {
|
||||
routes: Arc<Vec<ApiFixtureRoute>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApiFixtureManifest {
|
||||
routes: Vec<ApiFixtureManifestRoute>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApiFixtureManifestRoute {
|
||||
method: String,
|
||||
path: String,
|
||||
status: Option<u16>,
|
||||
content_type: Option<String>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
body: Option<String>,
|
||||
body_file: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ApiFixtureServer {
|
||||
base_url: String,
|
||||
handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl ApiFixtureServer {
|
||||
pub async fn start_default() -> Result<Self, String> {
|
||||
let fixture_set = ApiFixtureSet::from_default_manifest()?;
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|error| format!("failed to bind fixture API server: {error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("failed to read fixture API address: {error}"))?
|
||||
.port();
|
||||
let app = Router::new()
|
||||
.fallback(any(handle_fixture_request))
|
||||
.with_state(fixture_set.routes);
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
Ok(Self {
|
||||
base_url: format!("http://127.0.0.1:{port}"),
|
||||
handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ApiFixtureServer {
|
||||
fn drop(&mut self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiFixtureSet {
|
||||
fn from_default_manifest() -> Result<Self, String> {
|
||||
let manifest: ApiFixtureManifest =
|
||||
serde_json::from_str(include_str!("fixtures/api_routes.json"))
|
||||
.map_err(|error| format!("failed to parse API fixture manifest: {error}"))?;
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/parity/fixtures/api");
|
||||
let mut routes = Vec::with_capacity(manifest.routes.len());
|
||||
for route in manifest.routes {
|
||||
routes.push(ApiFixtureRoute {
|
||||
method: route.method.to_ascii_uppercase(),
|
||||
path: route.path,
|
||||
status: route.status.unwrap_or(200),
|
||||
content_type: route
|
||||
.content_type
|
||||
.unwrap_or_else(|| "application/json; charset=utf-8".to_owned()),
|
||||
headers: route.headers.unwrap_or_default(),
|
||||
body: load_body(&root, route.body, route.body_file)?,
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
routes: Arc::new(routes),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_fixture_request(
|
||||
State(routes): State<Arc<Vec<ApiFixtureRoute>>>,
|
||||
request: Request<Body>,
|
||||
) -> Response {
|
||||
let method = request.method().as_str();
|
||||
let path = request.uri().path();
|
||||
let fixture = routes
|
||||
.iter()
|
||||
.find(|route| route.method == method && route.path == path)
|
||||
.or_else(|| {
|
||||
routes
|
||||
.iter()
|
||||
.find(|route| route.method == "ANY" && route.path == path)
|
||||
});
|
||||
match fixture {
|
||||
Some(fixture) => fixture_response(fixture),
|
||||
None => (
|
||||
StatusCode::NOT_FOUND,
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||
)],
|
||||
format!(r#"{{"error":"missing fixture","method":"{method}","path":"{path}"}}"#),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_response(fixture: &ApiFixtureRoute) -> Response {
|
||||
let status = StatusCode::from_u16(fixture.status).unwrap_or(StatusCode::OK);
|
||||
let mut response = (status, fixture.body.clone()).into_response();
|
||||
if let Ok(value) = HeaderValue::from_str(&fixture.content_type) {
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, value);
|
||||
}
|
||||
for (name, value) in &fixture.headers {
|
||||
if let (Ok(name), Ok(value)) = (
|
||||
HeaderName::from_bytes(name.as_bytes()),
|
||||
HeaderValue::from_str(value),
|
||||
) {
|
||||
response.headers_mut().insert(name, value);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn load_body(
|
||||
root: &Path,
|
||||
body: Option<String>,
|
||||
body_file: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
if let Some(body) = body {
|
||||
return Ok(body);
|
||||
}
|
||||
let Some(body_file) = body_file else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
let path = root.join(body_file);
|
||||
std::fs::read_to_string(&path)
|
||||
.map_err(|error| format!("failed to read fixture {}: {error}", path.display()))
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::html_normalizer;
|
||||
use reqwest::{Client, redirect::Policy};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NormalizedResponse {
|
||||
pub status: u16,
|
||||
pub content_type: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
pub fn capture_client() -> Result<Client, String> {
|
||||
Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.map_err(|error| format!("failed to build capture client: {error}"))
|
||||
}
|
||||
|
||||
pub async fn fetch_route(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
route: &str,
|
||||
cookie: Option<&str>,
|
||||
) -> Result<NormalizedResponse, String> {
|
||||
let url = format!("{}{}", base_url.trim_end_matches('/'), route);
|
||||
let mut request = client.get(&url);
|
||||
if let Some(cookie) = cookie {
|
||||
request = request.header(reqwest::header::COOKIE, cookie);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("failed to fetch {url}: {error}"))?;
|
||||
let status = response.status().as_u16();
|
||||
let headers = response.headers().clone();
|
||||
let content_type = headers
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| html_normalizer::normalize_header_value("content-type", value));
|
||||
let location = headers
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| html_normalizer::normalize_header_value("location", value));
|
||||
let raw_body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("failed to read body for {url}: {error}"))?;
|
||||
let body = html_normalizer::normalize_body(content_type.as_deref(), &raw_body);
|
||||
Ok(NormalizedResponse {
|
||||
status,
|
||||
content_type,
|
||||
location,
|
||||
body,
|
||||
})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"user": {
|
||||
"id": "1130650140672000000",
|
||||
"username": "parity_admin",
|
||||
"discriminator": 0,
|
||||
"global_name": "Parity Admin",
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"bio": "Parity fixture admin user.",
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"email": "[email protected]",
|
||||
"email_verified": true,
|
||||
"email_bounced": false,
|
||||
"date_of_birth": "2000-01-01",
|
||||
"locale": "en-US",
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"acls": ["*"],
|
||||
"traits": [],
|
||||
"has_totp": true,
|
||||
"authenticator_types": [1],
|
||||
"last_active_at": "2026-05-26T15:00:00.000Z",
|
||||
"last_active_ip": "127.0.0.1",
|
||||
"last_active_ip_reverse": "localhost",
|
||||
"last_active_location": "Local"
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"guild": {
|
||||
"id": "1600000000000000001",
|
||||
"owner_id": "1508576042312688531",
|
||||
"owner_username": "parity_user",
|
||||
"owner_global_name": "Parity User",
|
||||
"owner_discriminator": "4363",
|
||||
"name": "Parity Guild",
|
||||
"vanity_url_code": "parity",
|
||||
"icon": null,
|
||||
"banner": null,
|
||||
"splash": null,
|
||||
"embed_splash": null,
|
||||
"features": ["COMMUNITY", "DISCOVERABLE"],
|
||||
"verification_level": 1,
|
||||
"mfa_level": 0,
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null,
|
||||
"explicit_content_filter": 2,
|
||||
"default_message_notifications": 1,
|
||||
"afk_channel_id": null,
|
||||
"afk_timeout": 300,
|
||||
"system_channel_id": null,
|
||||
"system_channel_flags": 0,
|
||||
"rules_channel_id": null,
|
||||
"disabled_operations": 0,
|
||||
"member_count": 128,
|
||||
"channels": [
|
||||
{
|
||||
"id": "1600000000000000101",
|
||||
"name": "general",
|
||||
"type": 0,
|
||||
"position": 0,
|
||||
"parent_id": null,
|
||||
"nsfw": false,
|
||||
"nsfw_override": null,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null,
|
||||
"url": null
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"id": "1600000000000000001",
|
||||
"name": "@everyone",
|
||||
"color": 0,
|
||||
"position": 0,
|
||||
"permissions": "0",
|
||||
"hoist": false,
|
||||
"mentionable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "1508576042312688531",
|
||||
"username": "parity_user",
|
||||
"discriminator": 4363,
|
||||
"global_name": "Parity User",
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"email": "[email protected]",
|
||||
"email_verified": true,
|
||||
"email_bounced": false,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-US",
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"acls": [],
|
||||
"traits": [],
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"last_active_at": "2026-05-26T15:00:00.000Z",
|
||||
"last_active_ip": "127.0.0.1",
|
||||
"last_active_ip_reverse": "localhost",
|
||||
"last_active_location": "Local"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"access_token": "parity-access-token",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"id": "1130650140672000000"
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"report_id": "1700000000000000001",
|
||||
"reporter_id": "1508576042312688531",
|
||||
"reporter_tag": "parity_user#4363",
|
||||
"reporter_username": "parity_user",
|
||||
"reporter_global_name": "Parity User",
|
||||
"reporter_discriminator": "4363",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-05-26T15:10:45.134Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "nsfw_violation",
|
||||
"additional_info": "Parity fixture report.",
|
||||
"reported_user_id": "1508576042312688531",
|
||||
"reported_user_tag": "parity_user#4363",
|
||||
"reported_user_username": "parity_user",
|
||||
"reported_user_global_name": "Parity User",
|
||||
"reported_user_discriminator": "4363",
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_guild_id": "1600000000000000001",
|
||||
"reported_guild_name": "Parity Guild",
|
||||
"reported_message_id": "1800000000000000001",
|
||||
"reported_channel_id": "1600000000000000101",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": null,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": null,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": null,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"mutual_dm_channel_id": null,
|
||||
"message_context": []
|
||||
}
|
||||
@@ -1,614 +0,0 @@
|
||||
{
|
||||
"report_id": "1556352159220498613",
|
||||
"reporter_id": "1556352159077892243",
|
||||
"reporter_tag": "target_a91da2c28aae#2089",
|
||||
"reporter_username": "target_a91da2c28aae",
|
||||
"reporter_global_name": "Jordan Target",
|
||||
"reporter_discriminator": "2089",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-04T17:12:47.653Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556352159149195428",
|
||||
"reported_user_tag": "Harbor_Helper#3966",
|
||||
"reported_user_username": "Harbor_Helper",
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": "3966",
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_user_bot": true,
|
||||
"reported_webhook_id": null,
|
||||
"reported_webhook_name": null,
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": null,
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": null,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": null,
|
||||
"reported_webhook_guild_id": null,
|
||||
"reported_webhook_created_at": null,
|
||||
"reported_webhook_creator_id": null,
|
||||
"reported_webhook_creator_tag": null,
|
||||
"reported_webhook_creator_username": null,
|
||||
"reported_webhook_creator_global_name": null,
|
||||
"reported_webhook_creator_discriminator": null,
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_guild_id": "1556352159090475158",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556352159161778343",
|
||||
"reported_channel_id": "1556352159103058073",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"mutual_dm_channel_id": null,
|
||||
"message_context": [
|
||||
{
|
||||
"id": "1556352159115640988",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.358Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159069503633",
|
||||
"author_username": "reporter_c03a2ee3db86",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "2642",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159119835293",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.359Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159073697938",
|
||||
"author_username": "unverified_7d9456caf467",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "4822",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159124029598",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.360Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159077892243",
|
||||
"author_username": "target_a91da2c28aae",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "2089",
|
||||
"author_avatar": "f829b914",
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159128223903",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Anyone up for a trade this weekend?",
|
||||
"timestamp": "2026-10-04T17:07:50.361Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159077892243",
|
||||
"author_username": "target_a91da2c28aae",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "2089",
|
||||
"author_avatar": "f829b914",
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159140806818",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Weekly bulletin: the garden swap starts Saturday.",
|
||||
"timestamp": "2026-10-04T17:07:50.364Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159132418208",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": "1556352159132418208",
|
||||
"author_bot": null,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159153389733",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.367Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159149195428",
|
||||
"author_username": "Harbor_Helper",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "3966",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": true,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159161778343",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Reminder: the garden swap moved to 10 AM.",
|
||||
"timestamp": "2026-10-04T17:07:50.369Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159149195428",
|
||||
"author_username": "Harbor_Helper",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "3966",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": true,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159212110003",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Night relay: the swap tables are packed away.",
|
||||
"timestamp": "2026-10-04T17:07:50.381Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159203721393",
|
||||
"author_username": "Night Relay",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": "1556352159203721393",
|
||||
"author_bot": null,
|
||||
"missing_attachments": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159186944173",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.375Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159216304308",
|
||||
"author_username": "DeletedUser",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": null,
|
||||
"missing_attachments": []
|
||||
}
|
||||
],
|
||||
"message_responses": [
|
||||
{
|
||||
"id": "1556352159115640988",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159069503633",
|
||||
"username": "reporter_c03a2ee3db86",
|
||||
"discriminator": "2642",
|
||||
"global_name": "Avery Reporter",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.358Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159119835293",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159073697938",
|
||||
"username": "unverified_7d9456caf467",
|
||||
"discriminator": "4822",
|
||||
"global_name": "Unverified Reporter",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.359Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159124029598",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159077892243",
|
||||
"username": "target_a91da2c28aae",
|
||||
"discriminator": "2089",
|
||||
"global_name": "Jordan Target",
|
||||
"avatar": "f829b914",
|
||||
"avatar_color": 4604377,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.360Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159128223903",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159077892243",
|
||||
"username": "target_a91da2c28aae",
|
||||
"discriminator": "2089",
|
||||
"global_name": "Jordan Target",
|
||||
"avatar": "f829b914",
|
||||
"avatar_color": 4604377,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Anyone up for a trade this weekend?",
|
||||
"timestamp": "2026-10-04T17:07:50.361Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159140806818",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159132418208",
|
||||
"username": "Harbor Bulletin",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Harbor Bulletin",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": "1556352159132418208",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Weekly bulletin: the garden swap starts Saturday.",
|
||||
"timestamp": "2026-10-04T17:07:50.364Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159153389733",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159149195428",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "3966",
|
||||
"global_name": null,
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.367Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159161778343",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159149195428",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "3966",
|
||||
"global_name": null,
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Reminder: the garden swap moved to 10 AM.",
|
||||
"timestamp": "2026-10-04T17:07:50.369Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159212110003",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159203721393",
|
||||
"username": "Night Relay",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Night Relay",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": "1556352159203721393",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Night relay: the swap tables are packed away.",
|
||||
"timestamp": "2026-10-04T17:07:50.381Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159186944173",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159216304308",
|
||||
"username": "DeletedUser",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Deleted User",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.375Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
}
|
||||
],
|
||||
"reported_profile_snapshot": {
|
||||
"captured_at": "2026-10-04T17:12:47.653Z",
|
||||
"user": {
|
||||
"id": "1556352159149195428",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "3966",
|
||||
"global_name": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"avatar": null,
|
||||
"banner": null
|
||||
},
|
||||
"member": {
|
||||
"guild_id": "1556352159090475158",
|
||||
"nick": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"joined_at": "2026-10-04T17:12:47.614Z",
|
||||
"avatar": null,
|
||||
"banner": null
|
||||
},
|
||||
"guild": null
|
||||
},
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "spam",
|
||||
"option_label": "Spam or unwanted ads",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
{
|
||||
"report_id": "1556114449264280806",
|
||||
"reporter_id": "1556114449113285826",
|
||||
"reporter_tag": "reporter_010908032cb3#7915",
|
||||
"reporter_username": "reporter_010908032cb3",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "7915",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-04T01:30:41.372Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": null,
|
||||
"reported_user_tag": null,
|
||||
"reported_user_username": null,
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": null,
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_webhook_id": "1556114449176200401",
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": "Harbor Bulletin",
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": 1,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": "1556114449146840266",
|
||||
"reported_webhook_guild_id": "1556114449134257351",
|
||||
"reported_webhook_created_at": "2026-10-04T01:23:15.892Z",
|
||||
"reported_webhook_creator_id": "1556114449125868741",
|
||||
"reported_webhook_creator_tag": "owner_aa696aafce86#7021",
|
||||
"reported_webhook_creator_username": "owner_aa696aafce86",
|
||||
"reported_webhook_creator_global_name": "Morgan Owner",
|
||||
"reported_webhook_creator_discriminator": "7021",
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_guild_id": "1556114449134257351",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556114449184589011",
|
||||
"reported_channel_id": "1556114449146840266",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"mutual_dm_channel_id": null,
|
||||
"message_context": [
|
||||
{
|
||||
"id": "1556114449159423181",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.888Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449113285826",
|
||||
"author_username": "reporter_010908032cb3",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "7915",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449163617486",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.889Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449117480131",
|
||||
"author_username": "unverified_b8bab8c7a8c5",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0943",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449167811791",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.890Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449121674436",
|
||||
"author_username": "target_8b1ee6a349df",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "9014",
|
||||
"author_avatar": "f829b914",
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449172006096",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Anyone up for a trade this weekend?",
|
||||
"timestamp": "2026-10-04T01:23:15.891Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449121674436",
|
||||
"author_username": "target_8b1ee6a349df",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "9014",
|
||||
"author_avatar": "f829b914",
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449184589011",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Weekly bulletin: the garden swap starts Saturday.",
|
||||
"timestamp": "2026-10-04T01:23:15.894Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449176200401",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": "1556114449176200401"
|
||||
},
|
||||
{
|
||||
"id": "1556114449255892196",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Night relay: the swap tables are packed away.",
|
||||
"timestamp": "2026-10-04T01:23:15.911Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449247503586",
|
||||
"author_username": "Night Relay",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": "1556114449247503586"
|
||||
},
|
||||
{
|
||||
"id": "1556114449230726366",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.905Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449260086501",
|
||||
"author_username": "DeletedUser",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449205560536",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Reminder: the garden swap moved to 10 AM.",
|
||||
"timestamp": "2026-10-04T01:23:15.899Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449192977621",
|
||||
"author_username": "Harbor_Helper",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "1238",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449197171926",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.897Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449192977621",
|
||||
"author_username": "Harbor_Helper",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "1238",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null
|
||||
}
|
||||
],
|
||||
"message_responses": [
|
||||
{
|
||||
"id": "1556114449159423181",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449113285826",
|
||||
"username": "reporter_010908032cb3",
|
||||
"discriminator": "7915",
|
||||
"global_name": "Avery Reporter",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.888Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449163617486",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449117480131",
|
||||
"username": "unverified_b8bab8c7a8c5",
|
||||
"discriminator": "0943",
|
||||
"global_name": "Unverified Reporter",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.889Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449167811791",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449121674436",
|
||||
"username": "target_8b1ee6a349df",
|
||||
"discriminator": "9014",
|
||||
"global_name": "Jordan Target",
|
||||
"avatar": "f829b914",
|
||||
"avatar_color": 4604377,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.890Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449172006096",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449121674436",
|
||||
"username": "target_8b1ee6a349df",
|
||||
"discriminator": "9014",
|
||||
"global_name": "Jordan Target",
|
||||
"avatar": "f829b914",
|
||||
"avatar_color": 4604377,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Anyone up for a trade this weekend?",
|
||||
"timestamp": "2026-10-04T01:23:15.891Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449184589011",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449176200401",
|
||||
"username": "Harbor Bulletin",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Harbor Bulletin",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": "1556114449176200401",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Weekly bulletin: the garden swap starts Saturday.",
|
||||
"timestamp": "2026-10-04T01:23:15.894Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449255892196",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449247503586",
|
||||
"username": "Night Relay",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Night Relay",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": "1556114449247503586",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Night relay: the swap tables are packed away.",
|
||||
"timestamp": "2026-10-04T01:23:15.911Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449230726366",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449260086501",
|
||||
"username": "DeletedUser",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Deleted User",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.905Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449205560536",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449192977621",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "1238",
|
||||
"global_name": null,
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Reminder: the garden swap moved to 10 AM.",
|
||||
"timestamp": "2026-10-04T01:23:15.899Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449197171926",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449192977621",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "1238",
|
||||
"global_name": null,
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.897Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
}
|
||||
],
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "spam",
|
||||
"option_label": "Spam or unwanted ads",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"guilds": [
|
||||
{
|
||||
"id": "1600000000000000001",
|
||||
"name": "Parity Guild",
|
||||
"icon": null,
|
||||
"banner": null,
|
||||
"owner_id": "1508576042312688531",
|
||||
"owner_username": "parity_user",
|
||||
"owner_global_name": "Parity User",
|
||||
"owner_discriminator": "4363",
|
||||
"member_count": 128,
|
||||
"features": ["COMMUNITY", "DISCOVERABLE"],
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"reports": [
|
||||
{
|
||||
"report_id": "1700000000000000001",
|
||||
"reporter_id": "1508576042312688531",
|
||||
"reporter_tag": "parity_user#4363",
|
||||
"reporter_username": "parity_user",
|
||||
"reporter_global_name": "Parity User",
|
||||
"reporter_discriminator": "4363",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-05-26T15:10:45.134Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "nsfw_violation",
|
||||
"additional_info": "Parity fixture report.",
|
||||
"reported_user_id": "1508576042312688531",
|
||||
"reported_user_tag": "parity_user#4363",
|
||||
"reported_user_username": "parity_user",
|
||||
"reported_user_global_name": "Parity User",
|
||||
"reported_user_discriminator": "4363",
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_guild_id": "1600000000000000001",
|
||||
"reported_guild_name": "Parity Guild",
|
||||
"reported_message_id": "1800000000000000001",
|
||||
"reported_channel_id": "1600000000000000101",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": null,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": null,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": null,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"mutual_dm_channel_id": null,
|
||||
"message_context": []
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"offset": 0,
|
||||
"limit": 25
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
{
|
||||
"reports": [
|
||||
{
|
||||
"report_id": "1556008115701286089",
|
||||
"reporter_id": "1556008115617399989",
|
||||
"reporter_tag": "reporter_f189c9468c17#0896",
|
||||
"reporter_username": "reporter_f189c9468c17",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "0896",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-03T18:30:33.848Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "child_safety",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556008115625788599",
|
||||
"reported_user_tag": "target_4a5a5ce687ff#6741",
|
||||
"reported_user_username": "target_4a5a5ce687ff",
|
||||
"reported_user_global_name": "Jordan Target",
|
||||
"reported_user_discriminator": "6741",
|
||||
"reported_user_avatar_hash": "f829b914",
|
||||
"reported_guild_id": null,
|
||||
"reported_guild_name": null,
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556008115697091784",
|
||||
"reported_channel_id": "1556008115692897479",
|
||||
"reported_channel_name": null,
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": null,
|
||||
"reported_guild_nsfw": null,
|
||||
"reported_guild_content_warning_level": null,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": null,
|
||||
"reported_channel_effective_content_warning_level": null,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": "csam",
|
||||
"reason_label": "Child sexual abuse material",
|
||||
"reason_highest_priority": true,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "abuse",
|
||||
"option_label": "Abusive or harmful content",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "abuse",
|
||||
"screen_title": "What does it involve?",
|
||||
"option_id": "sexual",
|
||||
"option_label": "Sexual content",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "sexual",
|
||||
"screen_title": "Tell us about the sexual content",
|
||||
"option_id": "minor_sexual",
|
||||
"option_label": "Sexualizing a minor",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "minor_sexual",
|
||||
"screen_title": "What did you see involving a minor?",
|
||||
"option_id": "csam",
|
||||
"option_label": "Real or AI-generated images or videos of child sexual abuse",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
},
|
||||
{
|
||||
"report_id": "1556008115705480394",
|
||||
"reporter_id": "1556008115617399989",
|
||||
"reporter_tag": "reporter_f189c9468c17#0896",
|
||||
"reporter_username": "reporter_f189c9468c17",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "0896",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-03T18:30:33.852Z",
|
||||
"status": 0,
|
||||
"report_type": 1,
|
||||
"category": "harassment",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556008115625788599",
|
||||
"reported_user_tag": "target_4a5a5ce687ff#6741",
|
||||
"reported_user_username": "target_4a5a5ce687ff",
|
||||
"reported_user_global_name": "Jordan Target",
|
||||
"reported_user_discriminator": "6741",
|
||||
"reported_user_avatar_hash": "f829b914",
|
||||
"reported_guild_id": null,
|
||||
"reported_guild_name": null,
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": null,
|
||||
"reported_channel_id": null,
|
||||
"reported_channel_name": null,
|
||||
"reported_channel_nsfw": null,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": null,
|
||||
"reported_guild_nsfw": null,
|
||||
"reported_guild_content_warning_level": null,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": null,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": null,
|
||||
"reported_channel_effective_content_warning_level": null,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "22a1e877519c31ca",
|
||||
"surface": "in_app",
|
||||
"locale": "de",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "profile_intro",
|
||||
"screen_title": "Report profile",
|
||||
"option_id": null,
|
||||
"option_label": null,
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "profile_parts",
|
||||
"screen_title": "Which parts of their profile are a problem?",
|
||||
"option_id": null,
|
||||
"option_label": null,
|
||||
"items": [
|
||||
{
|
||||
"id": "photo",
|
||||
"label": "Pictures"
|
||||
},
|
||||
{
|
||||
"id": "profile_text",
|
||||
"label": "Profile text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"screen_id": "root_user",
|
||||
"screen_title": "What's wrong with their profile?",
|
||||
"option_id": "abuse",
|
||||
"option_label": "Abusive or harmful content",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "profile_abuse",
|
||||
"screen_title": "What's harmful about their profile?",
|
||||
"option_id": "harassment",
|
||||
"option_label": "Their profile harasses or targets me or someone else",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
},
|
||||
{
|
||||
"report_id": "1556008115709674699",
|
||||
"reporter_id": "1556008115617399989",
|
||||
"reporter_tag": "reporter_f189c9468c17#0896",
|
||||
"reporter_username": "reporter_f189c9468c17",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "0896",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-03T18:30:33.856Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556008115625788599",
|
||||
"reported_user_tag": "target_4a5a5ce687ff#6741",
|
||||
"reported_user_username": "target_4a5a5ce687ff",
|
||||
"reported_user_global_name": "Jordan Target",
|
||||
"reported_user_discriminator": "6741",
|
||||
"reported_user_avatar_hash": "f829b914",
|
||||
"reported_guild_id": "1556008115638371514",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556008115676120259",
|
||||
"reported_channel_id": "1556008115650954429",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": null,
|
||||
"reason_label": null,
|
||||
"reason_highest_priority": null,
|
||||
"flow": null,
|
||||
"reporter_good_faith_confirmed": null
|
||||
}
|
||||
],
|
||||
"total": 3,
|
||||
"offset": 0,
|
||||
"limit": 25
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
{
|
||||
"reports": [
|
||||
{
|
||||
"report_id": "1556114449264280806",
|
||||
"reporter_id": "1556114449113285826",
|
||||
"reporter_tag": "reporter_010908032cb3#7915",
|
||||
"reporter_username": "reporter_010908032cb3",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "7915",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-04T01:30:41.372Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": null,
|
||||
"reported_user_tag": null,
|
||||
"reported_user_username": null,
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": null,
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_webhook_id": "1556114449176200401",
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": "Harbor Bulletin",
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": 1,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": "1556114449146840266",
|
||||
"reported_webhook_guild_id": "1556114449134257351",
|
||||
"reported_webhook_created_at": "2026-10-04T01:23:15.892Z",
|
||||
"reported_webhook_creator_id": "1556114449125868741",
|
||||
"reported_webhook_creator_tag": "owner_aa696aafce86#7021",
|
||||
"reported_webhook_creator_username": "owner_aa696aafce86",
|
||||
"reported_webhook_creator_global_name": "Morgan Owner",
|
||||
"reported_webhook_creator_discriminator": "7021",
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_guild_id": "1556114449134257351",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556114449184589011",
|
||||
"reported_channel_id": "1556114449146840266",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "spam",
|
||||
"option_label": "Spam or unwanted ads",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
},
|
||||
{
|
||||
"report_id": "1556114449268475111",
|
||||
"reporter_id": "1556114449113285826",
|
||||
"reporter_tag": "reporter_010908032cb3#7915",
|
||||
"reporter_username": "reporter_010908032cb3",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "7915",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-04T01:30:41.375Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556114449192977621",
|
||||
"reported_user_tag": "Harbor_Helper#1238",
|
||||
"reported_user_username": "Harbor_Helper",
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": "1238",
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_webhook_id": null,
|
||||
"reported_webhook_name": null,
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": null,
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": null,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": null,
|
||||
"reported_webhook_guild_id": null,
|
||||
"reported_webhook_created_at": null,
|
||||
"reported_webhook_creator_id": null,
|
||||
"reported_webhook_creator_tag": null,
|
||||
"reported_webhook_creator_username": null,
|
||||
"reported_webhook_creator_global_name": null,
|
||||
"reported_webhook_creator_discriminator": null,
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_guild_id": "1556114449134257351",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556114449205560536",
|
||||
"reported_channel_id": "1556114449146840266",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "spam",
|
||||
"option_label": "Spam or unwanted ads",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
}
|
||||
],
|
||||
"total": 2,
|
||||
"offset": 0,
|
||||
"limit": 50
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "1508576042312688531",
|
||||
"username": "parity_user",
|
||||
"discriminator": 4363,
|
||||
"global_name": "Parity User",
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"email": "[email protected]",
|
||||
"email_verified": true,
|
||||
"email_bounced": false,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-US",
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"acls": [],
|
||||
"traits": [],
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"last_active_at": "2026-05-26T15:00:00.000Z",
|
||||
"last_active_ip": "127.0.0.1",
|
||||
"last_active_ip_reverse": "localhost",
|
||||
"last_active_location": "Local"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/users/@me",
|
||||
"body_file": "admin_user_me.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/users/@me",
|
||||
"body_file": "oauth_user_me.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/oauth2/token",
|
||||
"body_file": "oauth_token.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/oauth2/token/revoke",
|
||||
"body": "{}"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/users",
|
||||
"body_file": "search_users.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/users/1508576042312688531",
|
||||
"body_file": "lookup_user.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/guilds",
|
||||
"body_file": "search_guilds.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/guilds/1600000000000000001",
|
||||
"body_file": "lookup_guild.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/reports",
|
||||
"body_file": "search_reports.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/reports/1700000000000000001",
|
||||
"body_file": "report_detail.json"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/api-keys",
|
||||
"body": "[]"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,648 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct Attribute {
|
||||
name: String,
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
pub fn normalize_body(content_type: Option<&str>, body: &str) -> String {
|
||||
let content_type = content_type.unwrap_or("").to_ascii_lowercase();
|
||||
if content_type.contains("css") {
|
||||
return normalize_css_body(body);
|
||||
}
|
||||
if content_type.contains("html") {
|
||||
return normalize_html(body);
|
||||
}
|
||||
if content_type.contains("json")
|
||||
&& let Ok(value) = serde_json::from_str::<Value>(body)
|
||||
{
|
||||
return serde_json::to_string(&value).unwrap_or_else(|_| normalize_text(body));
|
||||
}
|
||||
normalize_text(body)
|
||||
}
|
||||
|
||||
fn normalize_css_body(body: &str) -> String {
|
||||
if body.trim().is_empty() || body.contains("not available") {
|
||||
"__CSS_EMPTY__".to_owned()
|
||||
} else {
|
||||
"__CSS_OK__".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_html(input: &str) -> String {
|
||||
let input = normalize_text(input);
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative_start) = input[cursor..].find('<') {
|
||||
let start = cursor + relative_start;
|
||||
output.push_str(&input[cursor..start]);
|
||||
let Some(end) = find_tag_end(&input, start) else {
|
||||
output.push_str(&input[start..]);
|
||||
return finish_html(output);
|
||||
};
|
||||
let raw_tag = &input[start..=end];
|
||||
output.push_str(&normalize_tag(raw_tag));
|
||||
cursor = end + 1;
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
finish_html(output)
|
||||
}
|
||||
|
||||
pub fn normalize_text(input: &str) -> String {
|
||||
let mut value = input.replace("\r\n", "\n").replace('\r', "\n");
|
||||
value = normalize_host_ports(&value);
|
||||
value = strip_static_asset_queries(&value);
|
||||
value = strip_htmx_script_tags(&value);
|
||||
value = strip_rust_runtime_script_blocks(&value);
|
||||
value = normalize_intentional_rust_improvements(&value);
|
||||
value = replace_query_parameter_values(&value, "state", "__OAUTH_STATE__");
|
||||
value = replace_query_parameter_values(&value, "_csrf", "__CSRF_TOKEN__");
|
||||
value = replace_script_csrf_values(&value);
|
||||
value = normalize_cookie_tokens(&value);
|
||||
value.trim().to_owned()
|
||||
}
|
||||
|
||||
pub fn normalize_header_value(name: &str, value: &str) -> String {
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"content-type" => normalize_content_type(value),
|
||||
"location" => normalize_text(value),
|
||||
"cookie" | "set-cookie" => normalize_cookie_header(value),
|
||||
_ => normalize_text(value),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_content_type(value: &str) -> String {
|
||||
value
|
||||
.split(';')
|
||||
.map(|part| part.trim().to_ascii_lowercase())
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
pub fn normalize_cookie_header(value: &str) -> String {
|
||||
normalize_cookie_tokens(value)
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
fn replace_script_csrf_values(input: &str) -> String {
|
||||
let pattern = "var csrf=\"";
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative_start) = input[cursor..].find(pattern) {
|
||||
let start = cursor + relative_start;
|
||||
let token_start = start + pattern.len();
|
||||
output.push_str(&input[cursor..token_start]);
|
||||
let Some(relative_end) = input[token_start..].find('"') else {
|
||||
cursor = token_start;
|
||||
break;
|
||||
};
|
||||
let token_end = token_start + relative_end;
|
||||
let token = &input[token_start..token_end];
|
||||
if token.len() == 64 && token.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
||||
output.push_str("__CSRF_TOKEN__");
|
||||
} else {
|
||||
output.push_str(token);
|
||||
}
|
||||
cursor = token_end;
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn finish_html(output: String) -> String {
|
||||
let output = remove_empty_flash_container(&output);
|
||||
let output = decode_html_entities(&output);
|
||||
remove_between_tag_whitespace(&output).trim().to_owned()
|
||||
}
|
||||
|
||||
fn remove_empty_flash_container(input: &str) -> String {
|
||||
input
|
||||
.replace(
|
||||
r#"<div class="empty:hidden" id="flash-container"></div>"#,
|
||||
"",
|
||||
)
|
||||
.replace(
|
||||
r#"<div id="flash-container" class="empty:hidden"></div>"#,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
fn find_tag_end(input: &str, start: usize) -> Option<usize> {
|
||||
let mut quote: Option<char> = None;
|
||||
for (offset, ch) in input[start + 1..].char_indices() {
|
||||
match (quote, ch) {
|
||||
(Some(active), current) if current == active => quote = None,
|
||||
(None, '"' | '\'') => quote = Some(ch),
|
||||
(None, '>') => return Some(start + 1 + offset),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_tag(raw: &str) -> String {
|
||||
if raw.starts_with("<!--") {
|
||||
return String::new();
|
||||
}
|
||||
if raw.len() < 3 {
|
||||
return raw.to_owned();
|
||||
}
|
||||
let mut inner = raw[1..raw.len() - 1].trim();
|
||||
let self_closing = inner.ends_with('/');
|
||||
if self_closing {
|
||||
inner = inner[..inner.len() - 1].trim_end();
|
||||
}
|
||||
if inner.eq_ignore_ascii_case("!doctype html") {
|
||||
return String::new();
|
||||
}
|
||||
if inner.starts_with('!') || inner.starts_with('?') {
|
||||
return format!("<{}>", collapse_ascii_whitespace(inner));
|
||||
}
|
||||
if let Some(rest) = inner.strip_prefix('/') {
|
||||
return format!("</{}>", rest.trim().to_ascii_lowercase());
|
||||
}
|
||||
let (tag_name, rest) = split_tag_name(inner);
|
||||
let tag_name = tag_name.to_ascii_lowercase();
|
||||
let mut attributes = parse_attributes(rest);
|
||||
normalize_csrf_input_value(&tag_name, &mut attributes);
|
||||
let tag_attributes = attributes.clone();
|
||||
attributes.retain(|attribute| {
|
||||
!is_ignored_rust_runtime_attribute(&tag_name, &tag_attributes, attribute)
|
||||
});
|
||||
for attribute in &mut attributes {
|
||||
if let Some(value) = attribute.value.take() {
|
||||
attribute.value = Some(normalize_text(&decode_html_entities(&value)));
|
||||
}
|
||||
attribute.name = attribute.name.to_ascii_lowercase();
|
||||
if attribute.name == "class"
|
||||
&& let Some(ref mut value) = attribute.value
|
||||
{
|
||||
let mut tokens: Vec<&str> = value.split_whitespace().collect();
|
||||
tokens.retain(|token| *token != "font-normal");
|
||||
tokens.sort();
|
||||
*value = tokens.join(" ");
|
||||
}
|
||||
if attribute.value.as_deref() == Some("") && is_boolean_attribute(&attribute.name) {
|
||||
attribute.value = None;
|
||||
}
|
||||
}
|
||||
attributes.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then_with(|| left.value.cmp(&right.value))
|
||||
});
|
||||
let mut normalized = format!("<{tag_name}");
|
||||
for attribute in attributes {
|
||||
normalized.push(' ');
|
||||
normalized.push_str(&attribute.name);
|
||||
if let Some(value) = attribute.value {
|
||||
normalized.push_str("=\"");
|
||||
normalized.push_str(&escape_attr(&value));
|
||||
normalized.push('"');
|
||||
}
|
||||
}
|
||||
normalized.push('>');
|
||||
if self_closing && !is_void_element(&tag_name) {
|
||||
normalized.push_str("</");
|
||||
normalized.push_str(&tag_name);
|
||||
normalized.push('>');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn split_tag_name(inner: &str) -> (&str, &str) {
|
||||
let split_at = inner
|
||||
.find(|ch: char| ch.is_ascii_whitespace())
|
||||
.unwrap_or(inner.len());
|
||||
let name = &inner[..split_at];
|
||||
let rest = inner[split_at..].trim_start();
|
||||
(name, rest)
|
||||
}
|
||||
|
||||
fn parse_attributes(input: &str) -> Vec<Attribute> {
|
||||
let bytes = input.as_bytes();
|
||||
let mut attributes = Vec::new();
|
||||
let mut cursor = 0;
|
||||
while cursor < bytes.len() {
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
let name_start = cursor;
|
||||
while cursor < bytes.len() && !bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'='
|
||||
{
|
||||
cursor += 1;
|
||||
}
|
||||
if name_start == cursor {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
let name = input[name_start..cursor].to_owned();
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor >= bytes.len() || bytes[cursor] != b'=' {
|
||||
attributes.push(Attribute { name, value: None });
|
||||
continue;
|
||||
}
|
||||
cursor += 1;
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor >= bytes.len() {
|
||||
attributes.push(Attribute {
|
||||
name,
|
||||
value: Some(String::new()),
|
||||
});
|
||||
break;
|
||||
}
|
||||
let value = if bytes[cursor] == b'"' || bytes[cursor] == b'\'' {
|
||||
let quote = bytes[cursor];
|
||||
cursor += 1;
|
||||
let value_start = cursor;
|
||||
while cursor < bytes.len() && bytes[cursor] != quote {
|
||||
cursor += 1;
|
||||
}
|
||||
let value = input[value_start..cursor].to_owned();
|
||||
if cursor < bytes.len() {
|
||||
cursor += 1;
|
||||
}
|
||||
value
|
||||
} else {
|
||||
let value_start = cursor;
|
||||
while cursor < bytes.len() && !bytes[cursor].is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
input[value_start..cursor].to_owned()
|
||||
};
|
||||
attributes.push(Attribute {
|
||||
name,
|
||||
value: Some(value),
|
||||
});
|
||||
}
|
||||
attributes
|
||||
}
|
||||
|
||||
fn normalize_csrf_input_value(tag_name: &str, attributes: &mut [Attribute]) {
|
||||
if tag_name != "input" {
|
||||
return;
|
||||
}
|
||||
let is_csrf = attributes.iter().any(|attribute| {
|
||||
attribute.name.eq_ignore_ascii_case("name") && attribute.value.as_deref() == Some("_csrf")
|
||||
});
|
||||
if !is_csrf {
|
||||
return;
|
||||
}
|
||||
for attribute in attributes {
|
||||
if attribute.name.eq_ignore_ascii_case("value") {
|
||||
attribute.value = Some("__CSRF_TOKEN__".to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ignored_rust_runtime_attribute(
|
||||
tag_name: &str,
|
||||
tag_attributes: &[Attribute],
|
||||
attribute: &Attribute,
|
||||
) -> bool {
|
||||
let name = attribute.name.to_ascii_lowercase();
|
||||
if name.starts_with("hx-") {
|
||||
return true;
|
||||
}
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"popovertarget" | "popovertargetaction" | "popover"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
let is_drawer_panel = tag_name == "aside"
|
||||
&& tag_attributes
|
||||
.iter()
|
||||
.any(|attr| attr.name.eq_ignore_ascii_case("data-drawer-panel"));
|
||||
if is_drawer_panel && matches!(name.as_str(), "id" | "aria-hidden") {
|
||||
return true;
|
||||
}
|
||||
let is_drawer_body = tag_attributes
|
||||
.iter()
|
||||
.any(|attr| attr.name.eq_ignore_ascii_case("data-drawer-body"));
|
||||
if is_drawer_body && name == "id" {
|
||||
return true;
|
||||
}
|
||||
if name != "id" {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
attribute.value.as_deref(),
|
||||
Some(
|
||||
"users-results"
|
||||
| "guilds-results"
|
||||
| "applications-results"
|
||||
| "reports-results"
|
||||
| "user-peek"
|
||||
| "guild-peek"
|
||||
| "report-peek"
|
||||
| "guild-tab-content"
|
||||
| "user-tab-content"
|
||||
| "jobs-results"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_boolean_attribute(name: &str) -> bool {
|
||||
if name.starts_with("data-") {
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
name,
|
||||
"allowfullscreen"
|
||||
| "async"
|
||||
| "autofocus"
|
||||
| "autoplay"
|
||||
| "checked"
|
||||
| "controls"
|
||||
| "defer"
|
||||
| "disabled"
|
||||
| "hidden"
|
||||
| "loop"
|
||||
| "multiple"
|
||||
| "muted"
|
||||
| "open"
|
||||
| "readonly"
|
||||
| "required"
|
||||
| "selected"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_void_element(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"area"
|
||||
| "base"
|
||||
| "br"
|
||||
| "col"
|
||||
| "embed"
|
||||
| "hr"
|
||||
| "img"
|
||||
| "input"
|
||||
| "link"
|
||||
| "meta"
|
||||
| "param"
|
||||
| "source"
|
||||
| "track"
|
||||
| "wbr"
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_attr(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn decode_html_entities(value: &str) -> String {
|
||||
value
|
||||
.replace("&", "&")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(""", "\"")
|
||||
.replace("←", "\u{2190}")
|
||||
.replace("→", "\u{2192}")
|
||||
.replace("—", "\u{2014}")
|
||||
.replace("–", "\u{2013}")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
|
||||
fn collapse_ascii_whitespace(value: &str) -> String {
|
||||
value.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn remove_between_tag_whitespace(input: &str) -> String {
|
||||
let chars = input.chars().collect::<Vec<_>>();
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while cursor < chars.len() {
|
||||
output.push(chars[cursor]);
|
||||
if chars[cursor] == '>' {
|
||||
cursor += 1;
|
||||
let whitespace_start = cursor;
|
||||
while cursor < chars.len() && chars[cursor].is_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor < chars.len() && chars[cursor] == '<' {
|
||||
continue;
|
||||
}
|
||||
if cursor > whitespace_start {
|
||||
output.push(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn normalize_host_ports(input: &str) -> String {
|
||||
let value = replace_host_port(input, "127.0.0.1");
|
||||
replace_host_port(&value, "localhost")
|
||||
}
|
||||
|
||||
fn replace_host_port(input: &str, host: &str) -> String {
|
||||
let needle = format!("{host}:");
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative) = input[cursor..].find(&needle) {
|
||||
let start = cursor + relative;
|
||||
output.push_str(&input[cursor..start]);
|
||||
output.push_str(host);
|
||||
output.push_str(":__PORT__");
|
||||
cursor = start + needle.len();
|
||||
while cursor < input.len() && input.as_bytes()[cursor].is_ascii_digit() {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn strip_static_asset_queries(input: &str) -> String {
|
||||
let mut value = strip_query_after_path(input, "/static/app.css");
|
||||
value = strip_query_after_path(&value, "/static/htmx.min.js");
|
||||
value
|
||||
}
|
||||
|
||||
fn strip_htmx_script_tags(input: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative_start) = input[cursor..].find("<script") {
|
||||
let start = cursor + relative_start;
|
||||
let Some(tag_end) = find_tag_end(input, start) else {
|
||||
break;
|
||||
};
|
||||
let tag = &input[start..=tag_end];
|
||||
if tag.to_ascii_lowercase().contains("/static/htmx.min.js") {
|
||||
output.push_str(&input[cursor..start]);
|
||||
let after_tag = tag_end + 1;
|
||||
if input[after_tag..].starts_with("</script>") {
|
||||
cursor = after_tag + "</script>".len();
|
||||
} else {
|
||||
cursor = after_tag;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
output.push_str(&input[cursor..=tag_end]);
|
||||
cursor = tag_end + 1;
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn strip_rust_runtime_script_blocks(input: &str) -> String {
|
||||
let mut value =
|
||||
strip_script_blocks_containing(input, "document.body.addEventListener('showFlash'");
|
||||
value = strip_script_blocks_containing(&value, "__fluxerAdminHtmxScrollPreserver");
|
||||
value = strip_script_blocks_containing(&value, "window.__adminCopyToClipboard");
|
||||
value = strip_script_blocks_containing(&value, "window.__fluxerDrawerInit");
|
||||
value
|
||||
}
|
||||
|
||||
fn strip_script_blocks_containing(input: &str, needle: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative_start) = input[cursor..].find("<script") {
|
||||
let start = cursor + relative_start;
|
||||
let Some(tag_end) = find_tag_end(input, start) else {
|
||||
break;
|
||||
};
|
||||
let content_start = tag_end + 1;
|
||||
let Some(relative_end) = input[content_start..].find("</script>") else {
|
||||
break;
|
||||
};
|
||||
let end = content_start + relative_end + "</script>".len();
|
||||
let block = &input[start..end];
|
||||
if block.contains(needle) {
|
||||
output.push_str(&input[cursor..start]);
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
output.push_str(&input[cursor..end]);
|
||||
cursor = end;
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn normalize_intentional_rust_improvements(input: &str) -> String {
|
||||
input.replace(
|
||||
"flex flex-col gap-8 items-center",
|
||||
"flex flex-col gap-8 items-stretch",
|
||||
)
|
||||
}
|
||||
|
||||
fn strip_query_after_path(input: &str, path: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative) = input[cursor..].find(path) {
|
||||
let start = cursor + relative;
|
||||
output.push_str(&input[cursor..start]);
|
||||
output.push_str(path);
|
||||
cursor = start + path.len();
|
||||
if input[cursor..].starts_with('?') {
|
||||
cursor += 1;
|
||||
while cursor < input.len() && !is_url_delimiter(input.as_bytes()[cursor]) {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn replace_query_parameter_values(input: &str, name: &str, replacement: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while cursor < input.len() {
|
||||
let remaining = &input[cursor..];
|
||||
let query_needle = format!("?{name}=");
|
||||
let amp_needle = format!("&{name}=");
|
||||
let query_pos = remaining.find(&query_needle);
|
||||
let amp_pos = remaining.find(&_needle);
|
||||
let Some((relative, needle_len)) =
|
||||
closest_match(query_pos, query_needle.len(), amp_pos, amp_needle.len())
|
||||
else {
|
||||
output.push_str(remaining);
|
||||
break;
|
||||
};
|
||||
let start = cursor + relative;
|
||||
output.push_str(&input[cursor..start + needle_len]);
|
||||
output.push_str(replacement);
|
||||
cursor = start + needle_len;
|
||||
while cursor < input.len() && !is_url_delimiter(input.as_bytes()[cursor]) {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn closest_match(
|
||||
left: Option<usize>,
|
||||
left_len: usize,
|
||||
right: Option<usize>,
|
||||
right_len: usize,
|
||||
) -> Option<(usize, usize)> {
|
||||
match (left, right) {
|
||||
(Some(left), Some(right)) if left <= right => Some((left, left_len)),
|
||||
(Some(_left), Some(right)) => Some((right, right_len)),
|
||||
(Some(left), None) => Some((left, left_len)),
|
||||
(None, Some(right)) => Some((right, right_len)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_cookie_tokens(input: &str) -> String {
|
||||
let mut value = replace_prefixed_value(input, "admin_session=", "__SESSION__");
|
||||
value = replace_prefixed_value(&value, "session=", "__SESSION__");
|
||||
value = replace_prefixed_value(&value, "csrf_token=", "__CSRF_COOKIE__");
|
||||
value = replace_prefixed_value(&value, "oauth_state=", "__OAUTH_STATE__");
|
||||
replace_prefixed_value(&value, "flash=", "__FLASH__")
|
||||
}
|
||||
|
||||
fn replace_prefixed_value(input: &str, prefix: &str, replacement: &str) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut cursor = 0;
|
||||
while let Some(relative) = input[cursor..].find(prefix) {
|
||||
let start = cursor + relative;
|
||||
output.push_str(&input[cursor..start + prefix.len()]);
|
||||
output.push_str(replacement);
|
||||
cursor = start + prefix.len();
|
||||
while cursor < input.len() && !is_cookie_delimiter(input.as_bytes()[cursor]) {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
output.push_str(&input[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn is_url_delimiter(byte: u8) -> bool {
|
||||
matches!(
|
||||
byte,
|
||||
b'&' | b'"' | b'\'' | b'<' | b'>' | b')' | b' ' | b'\n' | b'\t'
|
||||
)
|
||||
}
|
||||
|
||||
fn is_cookie_delimiter(byte: u8) -> bool {
|
||||
matches!(byte, b';' | b'"' | b'\'' | b' ' | b'\n' | b'\t')
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod api_fixtures;
|
||||
pub mod capture;
|
||||
pub mod html_normalizer;
|
||||
pub mod rust_server;
|
||||
|
||||
pub const TEST_ADMIN_SECRET: &str = "test-admin-secret";
|
||||
pub const TEST_ADMIN_USER_ID: &str = "1130650140672000000";
|
||||
pub const TEST_ACCESS_TOKEN: &str = "parity-access-token";
|
||||
@@ -1,88 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::TEST_ADMIN_SECRET;
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::{net::TcpListener, task::JoinHandle, time::sleep};
|
||||
|
||||
pub struct RunningRustAdmin {
|
||||
base_url: String,
|
||||
handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl RunningRustAdmin {
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RunningRustAdmin {
|
||||
fn drop(&mut self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(api_endpoint: &str) -> Result<RunningRustAdmin, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|error| format!("failed to bind Rust admin server: {error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("failed to read Rust admin address: {error}"))?
|
||||
.port();
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let config = admin_config(port, api_endpoint, &base_url);
|
||||
let router = build_router(config);
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
wait_for_health(&base_url).await?;
|
||||
Ok(RunningRustAdmin { base_url, handle })
|
||||
}
|
||||
|
||||
fn admin_config(port: u16, api_endpoint: &str, admin_endpoint: &str) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port,
|
||||
secret_key_base: TEST_ADMIN_SECRET.to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint: api_endpoint.to_owned(),
|
||||
media_endpoint: format!("{api_endpoint}/media"),
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
reports_bucket_origin: "https://reports.example.test".to_owned(),
|
||||
admin_endpoint: admin_endpoint.to_owned(),
|
||||
web_app_endpoint: "http://127.0.0.1:8088".to_owned(),
|
||||
oauth_client_id: "1234567890123456789".to_owned(),
|
||||
oauth_client_secret: "test-admin-oauth-secret".to_owned(),
|
||||
oauth_redirect_uri: format!("{admin_endpoint}/oauth2_callback"),
|
||||
build_version: "parity".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_health(base_url: &str) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| format!("failed to build health client: {error}"))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let url = format!("{}/_health", base_url.trim_end_matches('/'));
|
||||
let mut last_error = String::new();
|
||||
while Instant::now() < deadline {
|
||||
match client.get(&url).send().await {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(response) => last_error = format!("health returned {}", response.status()),
|
||||
Err(error) => last_error = error.to_string(),
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
Err(format!("timed out waiting for {url}: {last_error}"))
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use axum::{
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
config::{AdminConfig, ProxyConfig},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -31,30 +31,6 @@ struct MockApi {
|
||||
requests: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creating_a_reset_link_shows_the_url_once_with_a_copy_button() {
|
||||
let app = setup(true, "username", vec!["*"]).await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let (status, body) = post_form(
|
||||
&app,
|
||||
&format!("/users/{TARGET_ID}?action=create_password_reset_link&tab=account"),
|
||||
&format!("_csrf={csrf_token}"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(app.saw(&format!(
|
||||
"POST /admin/users/{TARGET_ID}/password-reset-link"
|
||||
)));
|
||||
assert!(body.contains("Copy this link now. It is shown only once."));
|
||||
assert!(body.contains(&format!(r#"value="{RESET_URL}""#)));
|
||||
assert!(body.contains(&format!(r#"data-copy-value="{RESET_URL}""#)));
|
||||
assert!(body.contains("Copy Link"));
|
||||
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Create Password Reset Link"));
|
||||
assert!(!page.contains(RESET_URL));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_htmx_reset_link_request_gets_only_the_result_fragment() {
|
||||
let app = setup(true, "username", vec!["*"]).await;
|
||||
@@ -133,16 +109,6 @@ async fn the_revoke_recovery_kit_action_needs_its_acl_and_a_username_instance()
|
||||
assert!(!page.contains("Revoke Recovery Kit"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn username_instances_hide_email_actions_on_the_account_tab() {
|
||||
let app = setup(true, "username", vec!["*"]).await;
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Create Password Reset Link"));
|
||||
assert!(!page.contains("Send Password Reset"));
|
||||
assert!(!page.contains("Change Email"));
|
||||
assert!(!page.contains("Verify Email"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_reset_link_action_needs_its_acl() {
|
||||
let app = setup(true, "username", vec!["admin:authenticate", "user:lookup"]).await;
|
||||
@@ -152,52 +118,6 @@ async fn the_reset_link_action_needs_its_acl() {
|
||||
assert!(!page.contains("Send Password Reset"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_instances_keep_the_email_actions() {
|
||||
let app = setup(true, "email", vec!["*"]).await;
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Send Password Reset"));
|
||||
assert!(page.contains("Change Email"));
|
||||
assert!(page.contains("Verify Email"));
|
||||
assert!(!page.contains("Create Password Reset Link"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_email_ban_notice_stays_after_a_ban_action_on_a_username_instance() {
|
||||
let notice = "Accounts have no email address, so email bans have no effect.";
|
||||
let username = setup(true, "username", vec!["*"]).await;
|
||||
let username_csrf = csrf_token(&username).await;
|
||||
let (status, body) = post_form(
|
||||
&username,
|
||||
"/email-bans?action=ban",
|
||||
&format!("_csrf={username_csrf}&email="),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.contains("Value is required"));
|
||||
assert!(body.contains(notice));
|
||||
|
||||
let email = setup(true, "email", vec!["*"]).await;
|
||||
let email_csrf = csrf_token(&email).await;
|
||||
let (_, body) = post_form(
|
||||
&email,
|
||||
"/email-bans?action=ban",
|
||||
&format!("_csrf={email_csrf}&email="),
|
||||
)
|
||||
.await;
|
||||
assert!(body.contains("Value is required"));
|
||||
assert!(!body.contains(notice));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_admin_never_asks_discovery_for_the_sign_in_method() {
|
||||
let app = setup(false, "username", vec!["*"]).await;
|
||||
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
|
||||
assert!(page.contains("Send Password Reset"));
|
||||
assert!(!page.contains("Create Password Reset Link"));
|
||||
assert!(!app.saw("GET /.well-known/fluxer"));
|
||||
}
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
session_cookie: String,
|
||||
@@ -423,7 +343,6 @@ fn user(id: &str, username: &str, acls: &[&str]) -> Value {
|
||||
|
||||
fn test_config(api_endpoint: String, self_hosted: bool) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
extract::State,
|
||||
http::{Method, Request, StatusCode, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SECRET_KEY: &str = "username-tags-test-secret";
|
||||
const ADMIN_ID: &str = "1500000000000000000";
|
||||
const TARGET_ID: &str = "1500000000000000042";
|
||||
const DISCRIMINATOR_INPUT: &str = r#"name="discriminator""#;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockApi {
|
||||
account_identity: &'static str,
|
||||
unique_usernames: bool,
|
||||
target: Value,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn username_instances_show_humans_without_a_tag() {
|
||||
let page = account_page(true, "username", user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(page.contains(r#"<p class="break-words text-sm text-neutral-500">member</p>"#));
|
||||
assert!(page.contains(r#"<div class="truncate text-neutral-500 text-xs">lilith</div>"#));
|
||||
assert!(!page.contains("member#0000"));
|
||||
assert!(!page.contains("lilith#0000"));
|
||||
assert!(!page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_instances_with_random_tags_show_tags_and_allow_tag_changes() {
|
||||
let page =
|
||||
account_page_with(true, "email", false, user(TARGET_ID, "member", 1234, false)).await;
|
||||
assert!(page.contains("member#1234"));
|
||||
assert!(page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_instances_with_no_tags_show_humans_without_a_tag() {
|
||||
let page = account_page_with(true, "email", true, user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(page.contains(r#"<p class="break-words text-sm text-neutral-500">member</p>"#));
|
||||
assert!(!page.contains("member#0000"));
|
||||
assert!(!page.contains("lilith#0000"));
|
||||
assert!(!page.contains(DISCRIMINATOR_INPUT));
|
||||
assert!(page.contains("Send Password Reset"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn username_instances_never_show_tags_even_if_told_random() {
|
||||
let page =
|
||||
account_page_with(true, "username", false, user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(!page.contains("member#0000"));
|
||||
assert!(!page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn username_instances_keep_bot_tags() {
|
||||
let page = account_page(true, "username", user(TARGET_ID, "helper", 4363, true)).await;
|
||||
assert!(page.contains("helper#4363"));
|
||||
assert!(page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_instances_keep_the_zero_tag() {
|
||||
let page = account_page(true, "email", user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(page.contains("member#0000"));
|
||||
assert!(page.contains("lilith#0000"));
|
||||
assert!(page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_admin_keeps_the_zero_tag() {
|
||||
let page = account_page(false, "username", user(TARGET_ID, "member", 0, false)).await;
|
||||
assert!(page.contains("member#0000"));
|
||||
assert!(page.contains(DISCRIMINATOR_INPUT));
|
||||
}
|
||||
|
||||
async fn account_page(self_hosted: bool, account_identity: &'static str, target: Value) -> String {
|
||||
account_page_with(
|
||||
self_hosted,
|
||||
account_identity,
|
||||
account_identity == "username",
|
||||
target,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn account_page_with(
|
||||
self_hosted: bool,
|
||||
account_identity: &'static str,
|
||||
unique_usernames: bool,
|
||||
target: Value,
|
||||
) -> String {
|
||||
let api_endpoint = spawn_mock_api(MockApi {
|
||||
account_identity,
|
||||
unique_usernames,
|
||||
target,
|
||||
})
|
||||
.await;
|
||||
let router = build_router(test_config(api_endpoint, self_hosted));
|
||||
let session_value = session::create_session(ADMIN_ID, "test-token", SECRET_KEY);
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/users/{TARGET_ID}?tab=account"))
|
||||
.header(
|
||||
header::COOKIE,
|
||||
format!("{}={session_value}", session::SESSION_COOKIE_NAME),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
String::from_utf8(bytes.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
async fn spawn_mock_api(mock: MockApi) -> String {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, Router::new().fallback(mock_api).with_state(mock))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn mock_api(State(mock): State<MockApi>, method: Method, uri: Uri) -> Response {
|
||||
let target_user = format!("/admin/users/{TARGET_ID}");
|
||||
let target_sessions = format!("{target_user}/sessions");
|
||||
let target_credentials = format!("{target_user}/webauthn-credentials");
|
||||
match (method, uri.path()) {
|
||||
(Method::GET, "/admin/users/@me") => Json(json!({
|
||||
"user": user(ADMIN_ID, "lilith", 0, false)
|
||||
}))
|
||||
.into_response(),
|
||||
(Method::GET, "/.well-known/fluxer") => Json(json!({
|
||||
"features": {
|
||||
"premium_enabled": false,
|
||||
"account_identity": mock.account_identity,
|
||||
"tag_style": if mock.unique_usernames { "none" } else { "random" }
|
||||
}
|
||||
}))
|
||||
.into_response(),
|
||||
(Method::GET, p) if p == target_user => {
|
||||
Json(json!({ "users": [mock.target] })).into_response()
|
||||
}
|
||||
(Method::GET, p) if p == target_sessions => Json(json!({ "sessions": [] })).into_response(),
|
||||
(Method::GET, p) if p == target_credentials => Json(json!([])).into_response(),
|
||||
_ => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "message": "not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn user(id: &str, username: &str, discriminator: u16, bot: bool) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"username": username,
|
||||
"discriminator": discriminator,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"email": null,
|
||||
"email_verified": false,
|
||||
"email_bounced": false,
|
||||
"global_name": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-GB",
|
||||
"acls": ["*"],
|
||||
"traits": [],
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"bot": bot,
|
||||
"system": false,
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"deletion_audit_log_reason": null,
|
||||
"deletion_scheduled_by": null,
|
||||
"deletion_scheduled_at": null,
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"last_active_ip_reverse": null,
|
||||
"last_active_location": null
|
||||
})
|
||||
}
|
||||
|
||||
fn test_config(api_endpoint: String, self_hosted: bool) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint,
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: "https://admin.example.test".to_owned(),
|
||||
web_app_endpoint: "https://app.example.test".to_owned(),
|
||||
oauth_client_id: "admin-client".to_owned(),
|
||||
oauth_client_secret: "admin-secret".to_owned(),
|
||||
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use axum::{
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
config::{AdminConfig, ProxyConfig},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -313,7 +313,6 @@ fn admin_user() -> Value {
|
||||
|
||||
fn test_config(api_endpoint: String) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
|
||||
Reference in new issue
Block a user