mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
refactor(schema): simplify validation and OpenAPI generation (#2709)
This commit is contained in:
Generated
+1
@@ -1940,6 +1940,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
|
||||
+83
-1
@@ -35,8 +35,9 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
|
||||
}
|
||||
|
||||
let json_str = fs::read_to_string(&spec_path).expect("failed to read openapi-admin.json");
|
||||
let spec: openapiv3::OpenAPI =
|
||||
let mut spec: openapiv3::OpenAPI =
|
||||
serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json");
|
||||
adapt_progenitor_throttled_errors(&mut spec);
|
||||
|
||||
let mut settings = progenitor::GenerationSettings::new();
|
||||
settings.with_interface(progenitor::InterfaceStyle::Positional);
|
||||
@@ -54,6 +55,87 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
|
||||
fs::write(&output_path, content).expect("failed to write generated API code");
|
||||
}
|
||||
|
||||
fn adapt_progenitor_throttled_errors(spec: &mut openapiv3::OpenAPI) {
|
||||
let schemas = &spec
|
||||
.components
|
||||
.as_ref()
|
||||
.expect("missing API components")
|
||||
.schemas;
|
||||
let error = serde_json::to_value(schemas.get("Error").expect("missing Error schema"))
|
||||
.expect("failed to inspect Error schema");
|
||||
let mut throttled = serde_json::to_value(
|
||||
schemas
|
||||
.get("ThrottledError")
|
||||
.expect("missing ThrottledError schema"),
|
||||
)
|
||||
.expect("failed to inspect ThrottledError schema");
|
||||
assert_eq!(
|
||||
error["additionalProperties"],
|
||||
serde_json::json!({}),
|
||||
"Progenitor error adaptation requires Error to retain all additional fields"
|
||||
);
|
||||
let properties = throttled["properties"]
|
||||
.as_object_mut()
|
||||
.expect("ThrottledError must be an object schema");
|
||||
assert_eq!(
|
||||
properties
|
||||
.remove("retry_after")
|
||||
.expect("missing retry_after")["type"],
|
||||
"number"
|
||||
);
|
||||
assert_eq!(
|
||||
properties.remove("global").expect("missing global")["type"],
|
||||
"boolean"
|
||||
);
|
||||
assert_eq!(
|
||||
throttled, error,
|
||||
"ThrottledError must extend the common Error schema"
|
||||
);
|
||||
|
||||
for path in spec.paths.paths.values_mut() {
|
||||
let openapiv3::ReferenceOr::Item(path) = path else {
|
||||
panic!("Progenitor error adaptation requires inline API paths");
|
||||
};
|
||||
for operation in [
|
||||
&mut path.get,
|
||||
&mut path.put,
|
||||
&mut path.post,
|
||||
&mut path.delete,
|
||||
&mut path.options,
|
||||
&mut path.head,
|
||||
&mut path.patch,
|
||||
&mut path.trace,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let Some(response) = operation
|
||||
.responses
|
||||
.responses
|
||||
.get_mut(&openapiv3::StatusCode::Code(429))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let openapiv3::ReferenceOr::Item(response) = response else {
|
||||
panic!("Progenitor error adaptation requires inline 429 responses");
|
||||
};
|
||||
let schema = &mut response
|
||||
.content
|
||||
.get_mut("application/json")
|
||||
.expect("429 responses must return JSON")
|
||||
.schema;
|
||||
assert_eq!(
|
||||
schema,
|
||||
&Some(openapiv3::ReferenceOr::ref_(
|
||||
"#/components/schemas/ThrottledError"
|
||||
)),
|
||||
"Progenitor only supports one error type per operation"
|
||||
);
|
||||
*schema = Some(openapiv3::ReferenceOr::ref_("#/components/schemas/Error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Face {
|
||||
css_family: String,
|
||||
weight: u64,
|
||||
|
||||
+6399
-5340
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{CreateAdminApiKeyResponse, ListAdminApiKeyEntry};
|
||||
@@ -35,7 +35,7 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> {
|
||||
let key_id = generated_types::SnowflakeType::from(key_id.to_owned());
|
||||
let key_id = snowflake(key_id);
|
||||
self.generated()
|
||||
.delete_admin_api_key(&key_id)
|
||||
.await
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{Archive, ArchiveDownloadUrlResponse, ListArchivesResponse};
|
||||
@@ -12,7 +12,7 @@ impl AdminApiClient {
|
||||
include_attachments: bool,
|
||||
) -> ApiResult<Archive> {
|
||||
let body = generated_types::AdminArchiveCreateRequest {
|
||||
include_attachments: include_attachments.then_some(true),
|
||||
include_attachments,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -28,7 +28,7 @@ impl AdminApiClient {
|
||||
include_attachments: bool,
|
||||
) -> ApiResult<Archive> {
|
||||
let body = generated_types::AdminArchiveCreateRequest {
|
||||
include_attachments: include_attachments.then_some(true),
|
||||
include_attachments,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -78,15 +78,17 @@ impl AdminApiClient {
|
||||
subject_id: &str,
|
||||
archive_id: &str,
|
||||
) -> ApiResult<ArchiveDownloadUrlResponse> {
|
||||
let subject_type = generated_types::ArchiveSubjectTypeSchema::try_from(subject_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_archive_download(subject_type, subject_id, archive_id)
|
||||
.get_admin_archive_download(
|
||||
subject_type,
|
||||
&snowflake(subject_id),
|
||||
&snowflake(archive_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
|
||||
@@ -13,10 +13,7 @@ impl AdminApiClient {
|
||||
let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() };
|
||||
let response = self
|
||||
.generated()
|
||||
.purge_admin_guild_assets(
|
||||
&generated_types::SnowflakeType::from(guild_id.to_owned()),
|
||||
&body,
|
||||
)
|
||||
.purge_admin_guild_assets(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
#[cfg(test)]
|
||||
use super::types::AuditLogEntry;
|
||||
use super::types::AuditLogsListResponse;
|
||||
|
||||
pub struct SearchAuditLogsParams {
|
||||
@@ -38,67 +36,25 @@ impl AdminApiClient {
|
||||
let limit = params.limit.to_string();
|
||||
let offset = params.offset.to_string();
|
||||
let query_params = [
|
||||
(
|
||||
"q",
|
||||
nonempty_string(params.query.as_deref()).unwrap_or_default(),
|
||||
),
|
||||
("q", params.query.as_deref().unwrap_or_default()),
|
||||
(
|
||||
"admin_user_id",
|
||||
nonempty_string(params.admin_user_id.as_deref()).unwrap_or_default(),
|
||||
params.admin_user_id.as_deref().unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"target_type",
|
||||
nonempty_string(params.target_type.as_deref()).unwrap_or_default(),
|
||||
params.target_type.as_deref().unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"target_id",
|
||||
nonempty_string(params.target_id.as_deref()).unwrap_or_default(),
|
||||
),
|
||||
("sort_by", sort_by.unwrap_or_default()),
|
||||
("sort_order", sort_order.unwrap_or_default()),
|
||||
("limit", limit),
|
||||
("offset", offset),
|
||||
("target_id", params.target_id.as_deref().unwrap_or_default()),
|
||||
("sort_by", sort_by.as_deref().unwrap_or_default()),
|
||||
("sort_order", sort_order.as_deref().unwrap_or_default()),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
let query_params: Vec<(&str, &str)> = query_params
|
||||
.iter()
|
||||
.map(|(key, value)| (*key, value.as_str()))
|
||||
.collect();
|
||||
self.get("/admin/audit-logs", Some(&query_params)).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn audit_logs_response(
|
||||
response: generated_types::AuditLogsListResponseSchema,
|
||||
) -> ApiResult<AuditLogsListResponse> {
|
||||
Ok(AuditLogsListResponse {
|
||||
logs: response.logs.into_iter().map(audit_log_entry).collect(),
|
||||
total: crate::api::generated::number_to_u64(response.total, "total")
|
||||
.map_err(ApiError::Parse)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn audit_log_entry(entry: generated_types::AdminAuditLogResponseSchema) -> AuditLogEntry {
|
||||
AuditLogEntry {
|
||||
log_id: String::from(entry.log_id),
|
||||
admin_user_id: String::from(entry.admin_user_id),
|
||||
admin_user: None,
|
||||
action: entry.action,
|
||||
target_id: entry.target_id,
|
||||
target_type: entry.target_type,
|
||||
target_user: None,
|
||||
target_guild: None,
|
||||
target_channel: None,
|
||||
related_users: Default::default(),
|
||||
related_guilds: Default::default(),
|
||||
related_channels: Default::default(),
|
||||
audit_log_reason: entry.audit_log_reason,
|
||||
metadata: entry.metadata,
|
||||
created_at: entry.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
|
||||
let value = match value {
|
||||
"created_at" => "createdAt",
|
||||
@@ -113,12 +69,6 @@ fn audit_sort_order(value: &str) -> ApiResult<generated_types::ListAdminAuditLog
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn nonempty_string(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -135,10 +85,39 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_lossy_audit_totals() {
|
||||
let response = generated_types::AuditLogsListResponseSchema {
|
||||
logs: Vec::new(),
|
||||
total: 1.5,
|
||||
};
|
||||
assert!(audit_logs_response(response).is_err());
|
||||
for total in [serde_json::json!(1.5), serde_json::json!(-1)] {
|
||||
let response = serde_json::json!({"logs": [], "total": total});
|
||||
assert!(serde_json::from_value::<AuditLogsListResponse>(response).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_audit_fields_without_losing_generated_string_values() {
|
||||
let json = serde_json::json!({
|
||||
"logs": [{
|
||||
"log_id": "123456789012345678",
|
||||
"admin_user_id": "234567890123456789",
|
||||
"admin_user": null,
|
||||
"action": "USER_UPDATE",
|
||||
"target_id": "345678901234567890",
|
||||
"target_type": "user",
|
||||
"target_user": null,
|
||||
"target_guild": null,
|
||||
"target_channel": null,
|
||||
"related_users": {},
|
||||
"related_guilds": {},
|
||||
"related_channels": {},
|
||||
"audit_log_reason": "Account review",
|
||||
"metadata": {"field": "username"},
|
||||
"created_at": "2026-09-11T12:00:00.000Z"
|
||||
}],
|
||||
"total": 1
|
||||
});
|
||||
let generated: generated_types::AuditLogsListResponseSchema =
|
||||
serde_json::from_value(json.clone()).unwrap();
|
||||
assert_eq!(generated.logs[0].action.to_string(), "USER_UPDATE");
|
||||
|
||||
let response: AuditLogsListResponse = serde_json::from_value(json.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
|
||||
@@ -9,10 +9,12 @@ impl AdminApiClient {
|
||||
pub async fn ban_email(&self, email: &str) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"email",
|
||||
generated_types::BanEmailRequest {
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_1: Some(generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
}
|
||||
.into(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -28,7 +30,10 @@ impl AdminApiClient {
|
||||
pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"ip",
|
||||
generated_types::BanIpRequest { ip: ip.to_owned() }.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -44,7 +49,10 @@ impl AdminApiClient {
|
||||
pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
SUSPICIOUS_EMAIL_DOMAIN_LIST,
|
||||
suspicious_email_domain_request(domain)?.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_2: Some(suspicious_email_domain_request(domain)?),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -62,10 +70,12 @@ impl AdminApiClient {
|
||||
pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"phrase",
|
||||
generated_types::BanPhraseRequest {
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_3: Some(generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
}
|
||||
.into(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -81,14 +91,16 @@ impl AdminApiClient {
|
||||
pub async fn ban_url(&self, url: &str) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"url",
|
||||
generated_types::BanUrlRequest {
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_4: Some(generated_types::BanUrlRequest {
|
||||
category: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
url: url.to_owned(),
|
||||
}
|
||||
.into(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -104,15 +116,17 @@ impl AdminApiClient {
|
||||
pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"url-domain",
|
||||
generated_types::BanUrlDomainRequest {
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_5: Some(generated_types::BanUrlDomainRequest {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
match_subdomains,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}
|
||||
.into(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -131,16 +145,17 @@ impl AdminApiClient {
|
||||
sha256_hex: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanFileShaRequest {
|
||||
let body = generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_6: Some(generated_types::BanFileShaRequest {
|
||||
category: None,
|
||||
content_type: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
source_url: None,
|
||||
},
|
||||
);
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
self.post_void_with_reason(
|
||||
"/admin/blocklists/file-sha/entries",
|
||||
Some(&serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?),
|
||||
@@ -186,15 +201,17 @@ impl AdminApiClient {
|
||||
pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"avatar-hash",
|
||||
generated_types::BanAvatarHashRequest {
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_7: Some(generated_types::BanAvatarHashRequest {
|
||||
category: None,
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
notes: None,
|
||||
reason: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}
|
||||
.into(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -213,10 +230,7 @@ impl AdminApiClient {
|
||||
let body = generated_types::BanUserAvatarRequest::default();
|
||||
let response = self
|
||||
.generated()
|
||||
.ban_admin_user_avatar(
|
||||
&generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
&body,
|
||||
)
|
||||
.ban_admin_user_avatar(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -225,7 +239,10 @@ impl AdminApiClient {
|
||||
pub async fn ban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
PROFILE_SUBSTRING_LIST,
|
||||
profile_substring_request(scope, substring)?.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_8: Some(profile_substring_request(scope, substring)?),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -249,6 +266,7 @@ impl AdminApiClient {
|
||||
list_type: &str,
|
||||
body: generated_types::AdminBlocklistEntryCreateRequest,
|
||||
) -> ApiResult<()> {
|
||||
let list_type = blocklist_list_type(list_type)?;
|
||||
self.generated()
|
||||
.create_admin_blocklist_entry(list_type, &body)
|
||||
.await
|
||||
@@ -262,6 +280,7 @@ impl AdminApiClient {
|
||||
entry_value: &str,
|
||||
scope: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let list_type = blocklist_list_type(list_type)?;
|
||||
let scope = scope.map(blocklist_delete_scope).transpose()?;
|
||||
self.generated()
|
||||
.delete_admin_blocklist_entry(list_type, entry_value, scope)
|
||||
@@ -276,6 +295,7 @@ impl AdminApiClient {
|
||||
entry_value: &str,
|
||||
scope: Option<&str>,
|
||||
) -> ApiResult<BanCheckResult> {
|
||||
let list_type = blocklist_list_type(list_type)?;
|
||||
let scope = scope.map(blocklist_get_scope).transpose()?;
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -290,6 +310,11 @@ const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious";
|
||||
|
||||
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
|
||||
|
||||
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
|
||||
generated_types::AdminBlocklistListType::try_from(list_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn blocklist_entry_path(list_type: &str, entry_value: &str) -> String {
|
||||
format!(
|
||||
"/admin/blocklists/{}/entries/{}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::BulkJobResponse;
|
||||
@@ -13,15 +13,11 @@ impl AdminApiClient {
|
||||
remove_flags: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::UpdateUserFlagsAdminBulkJobCreateRequest {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateUserFlags {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
task:
|
||||
generated_types::UpdateUserFlagsAdminBulkJobCreateRequestTask::UpdateUserFlags,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
@@ -33,14 +29,11 @@ impl AdminApiClient {
|
||||
remove_flags: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequest {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateSuspiciousActivityFlags {
|
||||
add_flags: add_flags.to_vec(),
|
||||
remove_flags: remove_flags.to_vec(),
|
||||
task: generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequestTask::UpdateSuspiciousActivityFlags,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
@@ -52,14 +45,11 @@ impl AdminApiClient {
|
||||
remove_features: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequest {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateGuildFeatures {
|
||||
add_features: guild_features(add_features),
|
||||
guild_ids: snowflakes(guild_ids),
|
||||
remove_features: guild_features(remove_features),
|
||||
task: generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequestTask::UpdateGuildFeatures,
|
||||
},
|
||||
);
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
@@ -70,14 +60,10 @@ impl AdminApiClient {
|
||||
user_ids: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::AddGuildMembersAdminBulkJobCreateRequest {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::AddGuildMembers {
|
||||
guild_id: snowflake(guild_id),
|
||||
task:
|
||||
generated_types::AddGuildMembersAdminBulkJobCreateRequestTask::AddGuildMembers,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
@@ -87,13 +73,9 @@ impl AdminApiClient {
|
||||
user_ids: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::DeleteUserMessagesAdminBulkJobCreateRequest {
|
||||
task:
|
||||
generated_types::DeleteUserMessagesAdminBulkJobCreateRequestTask::DeleteUserMessages,
|
||||
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
@@ -106,37 +88,28 @@ impl AdminApiClient {
|
||||
public_reason: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::ScheduleUserDeletionAdminBulkJobCreateRequest {
|
||||
days_until_deletion: Some(
|
||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
|
||||
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||
days_until_deletion,
|
||||
"days_until_deletion",
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: crate::api::generated::deletion_reason_code(
|
||||
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
"reason_code",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
task: generated_types::ScheduleUserDeletionAdminBulkJobCreateRequestTask::ScheduleUserDeletion,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from)
|
||||
.collect()
|
||||
values.iter().map(|value| snowflake(value)).collect()
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
|
||||
|
||||
+105
-36
@@ -120,7 +120,7 @@ impl AdminApiClient {
|
||||
query_params: Option<&[(&str, &str)]>,
|
||||
) -> ApiResult<T> {
|
||||
let response = Self::send_request(self.request(Method::GET, path, query_params)).await?;
|
||||
self.parse_response(response).await
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post<T: DeserializeOwned>(
|
||||
@@ -152,7 +152,7 @@ impl AdminApiClient {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
|
||||
let response = Self::send_request(builder.json(body)).await?;
|
||||
self.parse_response(response).await
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post_with_reason<T: DeserializeOwned>(
|
||||
@@ -164,7 +164,7 @@ impl AdminApiClient {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
self.parse_response(response).await
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||||
@@ -200,7 +200,7 @@ impl AdminApiClient {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
self.parse_response(response).await
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn patch_typed_with_reason<T, B>(
|
||||
@@ -216,7 +216,7 @@ impl AdminApiClient {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
|
||||
let response = Self::send_request(builder.json(body)).await?;
|
||||
self.parse_response(response).await
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_with_reason<T: DeserializeOwned>(
|
||||
@@ -228,7 +228,7 @@ impl AdminApiClient {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
self.parse_response(response).await
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_typed_with_reason<T, B>(
|
||||
@@ -244,7 +244,7 @@ impl AdminApiClient {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
||||
let response = Self::send_request(builder.json(body)).await?;
|
||||
self.parse_response(response).await
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_void_with_reason(
|
||||
@@ -284,22 +284,22 @@ impl AdminApiClient {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
self.parse_response(response).await
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
|
||||
Self::check_response_status(response).await.map(drop)
|
||||
}
|
||||
|
||||
async fn check_response_status(response: reqwest::Response) -> ApiResult<reqwest::Response> {
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
return Ok(response);
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let text = response.text().await.map_err(|error| {
|
||||
let message = response.text().await.map_err(|error| {
|
||||
ApiError::Network(format!("failed to read error response body: {error}"))
|
||||
})?;
|
||||
Err(ApiError::Http {
|
||||
status,
|
||||
message: text,
|
||||
})
|
||||
}
|
||||
Err(ApiError::Http { status, message })
|
||||
}
|
||||
|
||||
pub(crate) fn generated(&self) -> &crate::api::generated::GeneratedClient {
|
||||
@@ -328,28 +328,16 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_response<T: DeserializeOwned>(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
) -> ApiResult<T> {
|
||||
let status = response.status();
|
||||
if status.as_u16() == 204 {
|
||||
return serde_json::from_value(serde_json::Value::Null)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()));
|
||||
}
|
||||
if !status.is_success() {
|
||||
let text = response.text().await.map_err(|error| {
|
||||
ApiError::Network(format!("failed to read error response body: {error}"))
|
||||
})?;
|
||||
return Err(ApiError::Http {
|
||||
status: status.as_u16(),
|
||||
message: text,
|
||||
});
|
||||
}
|
||||
let text = response
|
||||
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> ApiResult<T> {
|
||||
let response = Self::check_response_status(response).await?;
|
||||
let text = if response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
String::new()
|
||||
} else {
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?
|
||||
};
|
||||
if text.is_empty() {
|
||||
return serde_json::from_value(serde_json::Value::Null)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()));
|
||||
@@ -418,3 +406,84 @@ impl std::fmt::Display for ApiError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn response(status: u16, body: &'static str) -> reqwest::Response {
|
||||
axum::http::Response::builder()
|
||||
.status(status)
|
||||
.body(body)
|
||||
.expect("valid response")
|
||||
.into()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parses_successful_json_and_empty_responses() {
|
||||
for (status, body, expected) in [
|
||||
(200, r#"{"value":1}"#, json!({"value": 1})),
|
||||
(201, "[1,2]", json!([1, 2])),
|
||||
(202, "null", Value::Null),
|
||||
(200, "", Value::Null),
|
||||
(204, "ignored body", Value::Null),
|
||||
] {
|
||||
let actual: Value = AdminApiClient::parse_response(response(status, body))
|
||||
.await
|
||||
.expect("valid response body");
|
||||
assert_eq!(actual, expected, "HTTP {status}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_responses_preserve_null_deserialization_errors() {
|
||||
let expected = serde_json::from_value::<Vec<String>>(Value::Null)
|
||||
.expect_err("null is not a list")
|
||||
.to_string();
|
||||
for (status, body) in [(200, ""), (204, "ignored body")] {
|
||||
let error = AdminApiClient::parse_response::<Vec<String>>(response(status, body))
|
||||
.await
|
||||
.expect_err("missing list");
|
||||
assert_eq!(error.to_string(), format!("parse error: {expected}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_json_preserves_deserialization_errors() {
|
||||
for body in [" ", "{", "not JSON"] {
|
||||
let expected = serde_json::from_str::<Value>(body)
|
||||
.expect_err("malformed JSON")
|
||||
.to_string();
|
||||
let error = AdminApiClient::parse_response::<Value>(response(200, body))
|
||||
.await
|
||||
.expect_err("malformed response");
|
||||
assert_eq!(error.to_string(), format!("parse error: {expected}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn void_responses_do_not_parse_successful_bodies() {
|
||||
for status in [200, 201, 202, 204] {
|
||||
AdminApiClient::parse_void_response(response(status, "not JSON"))
|
||||
.await
|
||||
.expect("successful void response");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn typed_and_void_responses_preserve_http_errors() {
|
||||
for status in [302, 400, 403, 404, 500] {
|
||||
for body in ["", "plain error", r#"{"code":"FORBIDDEN"}"#] {
|
||||
let typed = AdminApiClient::parse_response::<Value>(response(status, body))
|
||||
.await
|
||||
.map(drop);
|
||||
let empty = AdminApiClient::parse_void_response(response(status, body)).await;
|
||||
for result in [typed, empty] {
|
||||
let error = result.expect_err("unsuccessful response");
|
||||
assert_eq!(error.to_string(), format!("HTTP {status}: {body}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,16 @@ impl AdminApiClient {
|
||||
duration_quantity: u32,
|
||||
) -> ApiResult<CodesResponse> {
|
||||
let body = generated_types::GenerateGiftCodesRequest {
|
||||
count: crate::api::generated::nonzero_u32(count, "count").map_err(ApiError::Parse)?,
|
||||
count: crate::api::generated::nonzero_u32(count, "count")
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
duration_quantity: crate::api::generated::nonzero_u32(
|
||||
duration_quantity,
|
||||
"duration_quantity",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
duration_type: generated_types::GenerateGiftCodesRequestDurationType::try_from(
|
||||
duration_type,
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
duration_type: generated_types::GiftCodeDurationTypeSchema::try_from(duration_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
@@ -16,8 +16,7 @@ impl AdminApiClient {
|
||||
.list_admin_discovery_applications()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
response
|
||||
.into_inner()
|
||||
Vec::from(response.into_inner())
|
||||
.into_iter()
|
||||
.map(pending_discovery_application)
|
||||
.collect()
|
||||
@@ -29,8 +28,7 @@ impl AdminApiClient {
|
||||
.list_admin_discovery_listings()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
response
|
||||
.into_inner()
|
||||
Vec::from(response.into_inner())
|
||||
.into_iter()
|
||||
.map(listed_guild)
|
||||
.collect()
|
||||
@@ -41,16 +39,13 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
reason: Option<&str>,
|
||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
|
||||
generated_types::ApprovedDiscoveryAdminApplicationUpdateRequest {
|
||||
let guild_id = snowflake(guild_id);
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Approved {
|
||||
reason: reason
|
||||
.map(generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestReason::try_from)
|
||||
.map(generated_types::DiscoveryReviewReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
status: generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestStatus::Approved,
|
||||
},
|
||||
);
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_admin_discovery_application(&guild_id, &body)
|
||||
@@ -64,18 +59,11 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
reason: &str,
|
||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequest {
|
||||
reason:
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestReason::try_from(
|
||||
reason,
|
||||
)
|
||||
let guild_id = snowflake(guild_id);
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Rejected {
|
||||
reason: generated_types::DiscoveryRejectionReason::try_from(reason)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
status:
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestStatus::Rejected,
|
||||
},
|
||||
);
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_admin_discovery_application(&guild_id, &body)
|
||||
@@ -89,7 +77,7 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
reason: &str,
|
||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let guild_id = snowflake(guild_id);
|
||||
let body = generated_types::DiscoveryAdminRemoveRequest {
|
||||
reason: generated_types::DiscoveryAdminRemoveRequestReason::try_from(reason)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
|
||||
@@ -16,6 +16,10 @@ pub use inner::types;
|
||||
|
||||
pub use inner::Client as GeneratedClient;
|
||||
|
||||
pub(crate) fn snowflake(value: &str) -> types::SnowflakeType {
|
||||
types::SnowflakeType::Variant0(value.to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn number_to_u64(value: f64, field: &str) -> Result<u64, String> {
|
||||
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
|
||||
if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > MAX_SAFE_INTEGER {
|
||||
@@ -126,10 +130,10 @@ mod tests {
|
||||
let response: SearchGuildsResponse =
|
||||
serde_json::from_value(json).expect("failed to deserialize SearchGuildsResponse");
|
||||
|
||||
assert_eq!(response.total as i64, 1);
|
||||
assert_eq!(response.total, 1.0);
|
||||
assert_eq!(response.guilds.len(), 1);
|
||||
assert_eq!(response.guilds[0].name, "Test Guild");
|
||||
assert_eq!(response.guilds[0].member_count, 42);
|
||||
assert_eq!(*response.guilds[0].member_count, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::snowflake;
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{ListGuildEmojisResponse, ListGuildStickersResponse};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn list_guild_emojis(&self, guild_id: &str) -> ApiResult<ListGuildEmojisResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let guild_id = snowflake(guild_id);
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_guild_emojis(&guild_id)
|
||||
@@ -20,7 +20,7 @@ impl AdminApiClient {
|
||||
&self,
|
||||
guild_id: &str,
|
||||
) -> ApiResult<ListGuildStickersResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let guild_id = snowflake(guild_id);
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_guild_stickers(&guild_id)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::reports::SearchReportsParams;
|
||||
use super::types::{
|
||||
GuildAuditLogResponse, GuildDetailInfo, GuildInfo, GuildUpdateResponse,
|
||||
ListGuildMembersResponse, LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse,
|
||||
@@ -28,13 +29,8 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult<GuildInfo> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_guild(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
|
||||
resp.guild
|
||||
self.lookup_guild(guild_id)
|
||||
.await?
|
||||
.map(GuildInfo::from)
|
||||
.ok_or_else(|| super::client::ApiError::Http {
|
||||
status: 404,
|
||||
@@ -144,8 +140,7 @@ impl AdminApiClient {
|
||||
let limit = limit
|
||||
.map(i32::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.map(generated_types::Int32Type::from);
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_guild_audit_logs(
|
||||
@@ -153,7 +148,7 @@ impl AdminApiClient {
|
||||
None,
|
||||
None,
|
||||
before.as_ref(),
|
||||
limit.as_ref(),
|
||||
limit,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -259,37 +254,27 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
self.search_reports(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(guild_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
self.search_reports(&SearchReportsParams {
|
||||
reported_guild_id: Some(guild_id),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GuildSettingsPatch {
|
||||
content_warning_level: Option<generated_types::ContentWarningLevel>,
|
||||
content_warning_level: Option<generated_types::ContentWarningLevelInput>,
|
||||
content_warning_text: Option<String>,
|
||||
default_message_notifications: Option<generated_types::DefaultMessageNotifications>,
|
||||
default_message_notifications: Option<generated_types::DefaultMessageNotificationsInput>,
|
||||
disabled_operations: Option<generated_types::GuildOperations>,
|
||||
explicit_content_filter: Option<generated_types::GuildExplicitContentFilter>,
|
||||
mfa_level: Option<generated_types::GuildMfaLevel>,
|
||||
explicit_content_filter: Option<generated_types::GuildExplicitContentFilterInput>,
|
||||
mfa_level: Option<generated_types::GuildMfaLevelInput>,
|
||||
nsfw: Option<bool>,
|
||||
nsfw_level: Option<generated_types::NsfwLevel>,
|
||||
verification_level: Option<generated_types::GuildVerificationLevel>,
|
||||
nsfw_level: Option<generated_types::NsfwLevelInput>,
|
||||
verification_level: Option<generated_types::GuildVerificationLevelInput>,
|
||||
}
|
||||
|
||||
fn search_guilds_response(
|
||||
@@ -317,7 +302,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
|
||||
owner_global_name: response.owner_global_name,
|
||||
owner_discriminator: response.owner_discriminator,
|
||||
member_count: crate::api::generated::i64_to_u64(
|
||||
i64::from(response.member_count),
|
||||
i64::from(i32::from(response.member_count)),
|
||||
"member_count",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
@@ -325,7 +310,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
|
||||
nsfw_level: response.nsfw_level.map(i32::from),
|
||||
nsfw: response.nsfw,
|
||||
content_warning_level: response.content_warning_level.map(i32::from),
|
||||
content_warning_text: response.content_warning_text,
|
||||
content_warning_text: response.content_warning_text.map(String::from),
|
||||
description: None,
|
||||
vanity_url_code: None,
|
||||
})
|
||||
@@ -338,9 +323,9 @@ fn guild_update_response(
|
||||
Ok(GuildUpdateResponse {
|
||||
guild: GuildInfo {
|
||||
id: String::from(guild.id),
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
banner: guild.banner,
|
||||
name: String::from(guild.name),
|
||||
icon: guild.icon.map(String::from),
|
||||
banner: guild.banner.map(String::from),
|
||||
owner_id: String::from(guild.owner_id),
|
||||
owner_username: None,
|
||||
owner_global_name: None,
|
||||
@@ -350,11 +335,11 @@ fn guild_update_response(
|
||||
"member_count",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
features: guild.features,
|
||||
features: guild.features.into_iter().map(String::from).collect(),
|
||||
nsfw_level: guild.nsfw_level.map(i32::from),
|
||||
nsfw: guild.nsfw,
|
||||
content_warning_level: guild.content_warning_level.map(i32::from),
|
||||
content_warning_text: guild.content_warning_text,
|
||||
content_warning_text: guild.content_warning_text.map(String::from),
|
||||
description: None,
|
||||
vanity_url_code: None,
|
||||
},
|
||||
@@ -380,10 +365,6 @@ fn guild_settings_request(
|
||||
})
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
|
||||
values
|
||||
.iter()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::snowflake;
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
|
||||
|
||||
@@ -39,7 +41,7 @@ impl AdminApiClient {
|
||||
pub async fn get_job(&self, job_id: &str) -> ApiResult<GetJobResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_job(job_id)
|
||||
.get_admin_job(&snowflake(job_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
@@ -43,7 +43,8 @@ impl AdminApiClient {
|
||||
attachment_id: snowflake(attachment_id),
|
||||
channel_id: snowflake(channel_id),
|
||||
confirmed_viewed: true,
|
||||
filename: filename.to_owned(),
|
||||
filename: generated_types::ReportAttachmentToNcmecRequestFilename::try_from(filename)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
message_id: snowflake(message_id),
|
||||
reporter_full_name:
|
||||
generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from(
|
||||
@@ -119,7 +120,7 @@ impl AdminApiClient {
|
||||
) -> ApiResult<MessageShredStatusResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_message_shred(job_id)
|
||||
.get_admin_message_shred(&snowflake(job_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -133,13 +134,15 @@ impl AdminApiClient {
|
||||
context_limit: u32,
|
||||
) -> ApiResult<LookupMessageResponse> {
|
||||
let context_limit = context_limit.to_string();
|
||||
let filename = generated_types::SearchAdminMessagesFilename::try_from(filename)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.search_admin_messages(
|
||||
Some(&snowflake(attachment_id)),
|
||||
&snowflake(channel_id),
|
||||
Some(context_limit.as_str()),
|
||||
Some(filename),
|
||||
Some(&filename),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -195,7 +198,3 @@ impl AdminApiClient {
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
+120
-58
@@ -1,10 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::snowflake;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SearchReportsParams<'a> {
|
||||
pub query: Option<&'a str>,
|
||||
pub status: Option<i32>,
|
||||
pub report_type: Option<i32>,
|
||||
pub category: Option<&'a str>,
|
||||
pub reporter_id: Option<&'a str>,
|
||||
pub reported_user_id: Option<&'a str>,
|
||||
pub reported_guild_id: Option<&'a str>,
|
||||
pub reported_channel_id: Option<&'a str>,
|
||||
pub guild_context_id: Option<&'a str>,
|
||||
pub resolved_by_admin_id: Option<&'a str>,
|
||||
pub sort_by: Option<&'a str>,
|
||||
pub sort_order: Option<&'a str>,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn list_reports(
|
||||
&self,
|
||||
@@ -26,7 +46,7 @@ impl AdminApiClient {
|
||||
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_report(report_id)
|
||||
.get_admin_report(&snowflake(report_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -50,51 +70,55 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn search_reports(
|
||||
&self,
|
||||
query: Option<&str>,
|
||||
status: Option<i32>,
|
||||
report_type: Option<i32>,
|
||||
category: Option<&str>,
|
||||
reporter_id: Option<&str>,
|
||||
reported_user_id: Option<&str>,
|
||||
reported_guild_id: Option<&str>,
|
||||
reported_channel_id: Option<&str>,
|
||||
guild_context_id: Option<&str>,
|
||||
resolved_by_admin_id: Option<&str>,
|
||||
sort_by: Option<&str>,
|
||||
sort_order: Option<&str>,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
params: &SearchReportsParams<'_>,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
let status = status.map(report_status).transpose()?.unwrap_or_default();
|
||||
let report_type = report_type
|
||||
let status = params
|
||||
.status
|
||||
.map(report_status)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let report_type = params
|
||||
.report_type
|
||||
.map(report_type_name)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let sort_by = sort_by.map(report_sort_by).transpose()?.unwrap_or_default();
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
let sort_by = params
|
||||
.sort_by
|
||||
.map(report_sort_by)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let limit = params.limit.to_string();
|
||||
let offset = params.offset.to_string();
|
||||
let query_params = [
|
||||
("q", query.unwrap_or_default()),
|
||||
("q", params.query.unwrap_or_default()),
|
||||
("status", status),
|
||||
("report_type", report_type),
|
||||
("category", category.unwrap_or_default()),
|
||||
("reporter_id", reporter_id.unwrap_or_default()),
|
||||
("reported_user_id", reported_user_id.unwrap_or_default()),
|
||||
("reported_guild_id", reported_guild_id.unwrap_or_default()),
|
||||
("category", params.category.unwrap_or_default()),
|
||||
("reporter_id", params.reporter_id.unwrap_or_default()),
|
||||
(
|
||||
"reported_user_id",
|
||||
params.reported_user_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_guild_id",
|
||||
params.reported_guild_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_channel_id",
|
||||
reported_channel_id.unwrap_or_default(),
|
||||
params.reported_channel_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"guild_context_id",
|
||||
params.guild_context_id.unwrap_or_default(),
|
||||
),
|
||||
("guild_context_id", guild_context_id.unwrap_or_default()),
|
||||
(
|
||||
"resolved_by_admin_id",
|
||||
resolved_by_admin_id.unwrap_or_default(),
|
||||
params.resolved_by_admin_id.unwrap_or_default(),
|
||||
),
|
||||
("sort_by", sort_by),
|
||||
("sort_order", sort_order.unwrap_or_default()),
|
||||
("sort_order", params.sort_order.unwrap_or_default()),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
@@ -107,22 +131,12 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
self.search_reports(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(reporter_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
self.search_reports(&SearchReportsParams {
|
||||
reporter_id: Some(reporter_id),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -132,22 +146,12 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
self.search_reports(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(reported_user_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
self.search_reports(&SearchReportsParams {
|
||||
reported_user_id: Some(reported_user_id),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -179,3 +183,61 @@ fn report_sort_by(value: &str) -> ApiResult<&'static str> {
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn report_statuses_preserve_the_closed_wire_mapping() {
|
||||
for (value, expected) in [(0, "pending"), (1, "resolved")] {
|
||||
assert_eq!(report_status(value).expect("supported status"), expected);
|
||||
}
|
||||
for value in [-1, 2] {
|
||||
assert_eq!(
|
||||
report_status(value)
|
||||
.expect_err("unknown status")
|
||||
.to_string(),
|
||||
format!("parse error: unknown report status: {value}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_types_preserve_the_closed_wire_mapping() {
|
||||
for (value, expected) in [(0, "message"), (1, "user"), (2, "guild")] {
|
||||
assert_eq!(report_type_name(value).expect("supported type"), expected);
|
||||
}
|
||||
for value in [-1, 3] {
|
||||
assert_eq!(
|
||||
report_type_name(value)
|
||||
.expect_err("unknown type")
|
||||
.to_string(),
|
||||
format!("parse error: unknown report type: {value}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_sort_fields_accept_only_the_existing_aliases() {
|
||||
for (field, expected) in [
|
||||
("createdAt", "created_at"),
|
||||
("created_at", "created_at"),
|
||||
("reportedAt", "reported_at"),
|
||||
("reported_at", "reported_at"),
|
||||
("resolvedAt", "resolved_at"),
|
||||
("resolved_at", "resolved_at"),
|
||||
] {
|
||||
assert_eq!(
|
||||
report_sort_by(field).expect("supported sort field"),
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
report_sort_by("unknown")
|
||||
.expect_err("unknown sort field")
|
||||
.to_string(),
|
||||
"parse error: unknown report sort field: unknown"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{IndexRefreshStatusResponse, RefreshSearchIndexResponse};
|
||||
@@ -11,8 +11,11 @@ impl AdminApiClient {
|
||||
index_type: &str,
|
||||
guild_id: Option<&str>,
|
||||
) -> ApiResult<RefreshSearchIndexResponse> {
|
||||
let index_type =
|
||||
generated_types::CreateAdminSearchIndexRefreshIndexName::try_from(index_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::RefreshSearchIndexRequest {
|
||||
guild_id: guild_id.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
||||
guild_id: guild_id.map(snowflake),
|
||||
user_id: None,
|
||||
};
|
||||
let response = self
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{
|
||||
@@ -24,11 +24,7 @@ impl AdminApiClient {
|
||||
guild_ids: &[String],
|
||||
) -> ApiResult<ReloadAllGuildsResponse> {
|
||||
let body = generated_types::ReloadGuildsRequest {
|
||||
guild_ids: guild_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from)
|
||||
.collect(),
|
||||
guild_ids: guild_ids.iter().map(|id| snowflake(id)).collect(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::SendSystemDmResponse;
|
||||
@@ -14,11 +14,7 @@ impl AdminApiClient {
|
||||
let body = generated_types::SendSystemDmRequest {
|
||||
content: generated_types::SendSystemDmRequestContent::try_from(content)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
user_ids: user_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from)
|
||||
.collect(),
|
||||
user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InstanceConfigResponse {
|
||||
pub sso: SsoConfigResponse,
|
||||
@@ -443,19 +445,6 @@ impl VoiceE2eeScope {
|
||||
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NoiseSuppressionBackend {
|
||||
None,
|
||||
#[default]
|
||||
Standard,
|
||||
Gate,
|
||||
Speex,
|
||||
Rnnoise,
|
||||
Gtcrn,
|
||||
DeepFilter,
|
||||
}
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::None,
|
||||
@@ -467,18 +456,6 @@ impl NoiseSuppressionBackend {
|
||||
Self::DeepFilter,
|
||||
];
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Standard => "standard",
|
||||
Self::Gate => "gate",
|
||||
Self::Speex => "speex",
|
||||
Self::Rnnoise => "rnnoise",
|
||||
Self::Gtcrn => "gtcrn",
|
||||
Self::DeepFilter => "deep_filter",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None (pass-through)",
|
||||
@@ -490,12 +467,6 @@ impl NoiseSuppressionBackend {
|
||||
Self::DeepFilter => "DeepFilterNet",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_value(value: &str) -> Option<Self> {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|backend| backend.as_str() == value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
@@ -505,30 +476,19 @@ pub struct VoiceNoiseSuppressionGuildOverride {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct VoiceNoiseSuppressionConfigResponse {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub config_version: u64,
|
||||
#[serde(default)]
|
||||
pub default_backend: NoiseSuppressionBackend,
|
||||
#[serde(default = "default_voice_noise_suppression_enabled_backends")]
|
||||
pub enabled_backends: Vec<NoiseSuppressionBackend>,
|
||||
#[serde(default = "default_voice_noise_suppression_allow_user_override")]
|
||||
pub allow_user_override: bool,
|
||||
#[serde(default)]
|
||||
pub rollout_basis_points: u32,
|
||||
#[serde(default = "default_voice_noise_suppression_rollout_salt")]
|
||||
pub rollout_salt: String,
|
||||
#[serde(default)]
|
||||
pub included_user_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
|
||||
#[serde(default)]
|
||||
pub stereo_enabled: bool,
|
||||
#[serde(default = "default_voice_noise_suppression_strength")]
|
||||
pub suppression_strength: u32,
|
||||
}
|
||||
|
||||
@@ -538,35 +498,19 @@ impl Default for VoiceNoiseSuppressionConfigResponse {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
default_backend: NoiseSuppressionBackend::Standard,
|
||||
enabled_backends: default_voice_noise_suppression_enabled_backends(),
|
||||
allow_user_override: default_voice_noise_suppression_allow_user_override(),
|
||||
enabled_backends: NoiseSuppressionBackend::ALL.to_vec(),
|
||||
allow_user_override: true,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: default_voice_noise_suppression_rollout_salt(),
|
||||
rollout_salt: "voice-ns-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
guild_overrides: Vec::new(),
|
||||
stereo_enabled: false,
|
||||
suppression_strength: default_voice_noise_suppression_strength(),
|
||||
suppression_strength: 80,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_voice_noise_suppression_enabled_backends() -> Vec<NoiseSuppressionBackend> {
|
||||
NoiseSuppressionBackend::ALL.to_vec()
|
||||
}
|
||||
|
||||
fn default_voice_noise_suppression_allow_user_override() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_voice_noise_suppression_rollout_salt() -> String {
|
||||
"voice-ns-v1".to_owned()
|
||||
}
|
||||
|
||||
fn default_voice_noise_suppression_strength() -> u32 {
|
||||
80
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -594,30 +538,21 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
#[serde(default = "default_experiment_poll_interval_seconds")]
|
||||
pub poll_interval_seconds: u64,
|
||||
#[serde(default = "default_experiment_poll_jitter_percent")]
|
||||
pub poll_jitter_percent: u32,
|
||||
}
|
||||
|
||||
impl Default for ExperimentDeliveryConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_interval_seconds: default_experiment_poll_interval_seconds(),
|
||||
poll_jitter_percent: default_experiment_poll_jitter_percent(),
|
||||
poll_interval_seconds: 300,
|
||||
poll_jitter_percent: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_experiment_poll_interval_seconds() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
fn default_experiment_poll_jitter_percent() -> u32 {
|
||||
15
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ExperimentDeliveryConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1024,31 +959,14 @@ pub struct CreateRegistrationUrlResponse {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const VOICE_NOISE_SUPPRESSION_SCHEMA: &str = include_str!(
|
||||
"../../../../packages/schema/src/domains/admin/VoiceNoiseSuppressionSchemas.ts"
|
||||
);
|
||||
|
||||
fn schema_backend_ids() -> Vec<String> {
|
||||
let (_, rest) = VOICE_NOISE_SUPPRESSION_SCHEMA
|
||||
.split_once("export const VOICE_NOISE_SUPPRESSION_BACKENDS = [")
|
||||
.expect("backend list start");
|
||||
let (block, _) = rest.split_once(']').expect("backend list end");
|
||||
block
|
||||
.split(',')
|
||||
.map(|entry| entry.trim().trim_matches('\'').to_owned())
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.collect()
|
||||
}
|
||||
use crate::api::generated::types as generated_types;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_backend_ids_are_the_documented_list() {
|
||||
fn noise_suppression_backend_choices_use_the_generated_wire_contract() {
|
||||
assert_eq!(
|
||||
NoiseSuppressionBackend::ALL
|
||||
.iter()
|
||||
.map(NoiseSuppressionBackend::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
serde_json::to_value(NoiseSuppressionBackend::ALL).expect("serializable backends"),
|
||||
json!([
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
@@ -1056,29 +974,70 @@ mod tests {
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
]
|
||||
])
|
||||
);
|
||||
assert!(serde_json::from_value::<NoiseSuppressionBackend>(json!("deepfilter")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_backend_ids_match_the_schema_contract() {
|
||||
fn default_instance_experiment_config_matches_the_published_contract() {
|
||||
let schema: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../openapi-admin.json"))
|
||||
.expect("admin schema");
|
||||
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
|
||||
.expect("default noise config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let noise = serde_json::to_value(noise).expect("serializable noise config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
|
||||
serde_json::from_value(noise.clone()).expect("generated noise config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
assert_eq!(
|
||||
NoiseSuppressionBackend::ALL
|
||||
.iter()
|
||||
.map(|backend| backend.as_str().to_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
schema_backend_ids()
|
||||
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
|
||||
noise
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
.expect("serializable generated delivery config"),
|
||||
delivery
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
assert_eq!(
|
||||
value, &schema["components"]["schemas"][name]["properties"][field]["default"],
|
||||
"{name}.{field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_backend_round_trips_every_id() {
|
||||
for backend in NoiseSuppressionBackend::ALL {
|
||||
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
enabled_backends: Some(Vec::new()),
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
guild_overrides: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::VoiceNoiseSuppressionConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
NoiseSuppressionBackend::from_value(backend.as_str()),
|
||||
Some(backend)
|
||||
value,
|
||||
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
assert_eq!(NoiseSuppressionBackend::from_value("deepfilter"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
@@ -362,11 +362,8 @@ impl AdminApiClient {
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserUsernameUpdateRequest {
|
||||
discriminator: discriminator
|
||||
.map(generated_types::DiscriminatorType::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
username: generated_types::UsernameType::try_from(username)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
.map(|value| generated_types::DiscriminatorType::String(value.to_owned())),
|
||||
username: generated_types::UsernameType::from(username.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -399,7 +396,8 @@ impl AdminApiClient {
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserBanRequest {
|
||||
duration_hours: i32::try_from(duration_hours)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.into(),
|
||||
reason: reason.map(std::borrow::ToOwned::to_owned),
|
||||
};
|
||||
let resp: UserMutationResponse = self
|
||||
@@ -430,10 +428,12 @@ impl AdminApiClient {
|
||||
days_until_deletion: u32,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserDeletionScheduleRequest {
|
||||
days_until_deletion: Some(
|
||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||
days_until_deletion,
|
||||
"days_until_deletion",
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
|
||||
.map_err(ApiError::Parse)?,
|
||||
@@ -484,10 +484,10 @@ impl AdminApiClient {
|
||||
target_id: &str,
|
||||
category: &str,
|
||||
) -> ApiResult<()> {
|
||||
let category = generated_types::RemoveAdminUserRelationshipCategory::try_from(category)
|
||||
let category = generated_types::RelationshipCategoryEnum::try_from(category)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
self.generated()
|
||||
.remove_admin_user_relationship(&snowflake(user_id), target_id, category)
|
||||
.remove_admin_user_relationship(&snowflake(user_id), &snowflake(target_id), category)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
@@ -498,7 +498,7 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
category: &str,
|
||||
) -> ApiResult<super::types::RemoveRelationshipsResponse> {
|
||||
let category = generated_types::ClearAdminUserRelationshipsCategory::try_from(category)
|
||||
let category = generated_types::RelationshipCategoryEnum::try_from(category)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -565,10 +565,6 @@ fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
|
||||
values
|
||||
.iter()
|
||||
|
||||
@@ -55,10 +55,11 @@ impl AdminApiClient {
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<UpdateVoiceRegionResponse> {
|
||||
let region_id = required_field(params, "id")?;
|
||||
validate_against::<generated_types::UpdateVoiceRegionRequest>(params)?;
|
||||
let body = voice_request_body(params, &["id"])?;
|
||||
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)?;
|
||||
self.patch_with_reason(
|
||||
&format!("/admin/voice/regions/{}", urlencoding::encode(®ion_id)),
|
||||
Some(params),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -101,8 +102,9 @@ impl AdminApiClient {
|
||||
) -> ApiResult<CreateVoiceServerResponse> {
|
||||
let region_id = required_field(params, "region_id")?;
|
||||
paired_coordinates(params)?;
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::CreateVoiceServerRequest>(params.clone())
|
||||
let body = serde_json::from_value::<generated_types::CreateVoiceServerRequestBody>(
|
||||
voice_request_body(params, &["region_id"])?,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -119,14 +121,15 @@ impl AdminApiClient {
|
||||
let region_id = required_field(params, "region_id")?;
|
||||
let server_id = required_field(params, "server_id")?;
|
||||
paired_coordinates(params)?;
|
||||
validate_against::<generated_types::UpdateVoiceServerRequest>(params)?;
|
||||
let body = voice_request_body(params, &["region_id", "server_id"])?;
|
||||
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)?;
|
||||
self.patch_with_reason(
|
||||
&format!(
|
||||
"/admin/voice/regions/{}/servers/{}",
|
||||
urlencoding::encode(®ion_id),
|
||||
urlencoding::encode(&server_id)
|
||||
),
|
||||
Some(params),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -156,6 +159,20 @@ fn validate_against<T: serde::de::DeserializeOwned>(params: &serde_json::Value)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn voice_request_body(
|
||||
params: &serde_json::Value,
|
||||
path_fields: &[&str],
|
||||
) -> ApiResult<serde_json::Value> {
|
||||
let mut body = params
|
||||
.as_object()
|
||||
.ok_or_else(|| ApiError::Parse("voice request body must be an object".to_owned()))?
|
||||
.clone();
|
||||
for field in path_fields {
|
||||
body.remove(*field);
|
||||
}
|
||||
Ok(body.into())
|
||||
}
|
||||
|
||||
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
|
||||
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
|
||||
if has_coordinate("latitude") == has_coordinate("longitude") {
|
||||
@@ -174,3 +191,67 @@ fn required_field(params: &serde_json::Value, field: &str) -> ApiResult<String>
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
.ok_or_else(|| ApiError::Parse(format!("{field} is required")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn region_update_body_preserves_explicit_restriction_clears() {
|
||||
let expected = json!({
|
||||
"required_guild_features": [],
|
||||
"allowed_guild_ids": [],
|
||||
"allowed_user_ids": [],
|
||||
});
|
||||
let mut params = expected.clone();
|
||||
params["id"] = json!("eu");
|
||||
let body = voice_request_body(¶ms, &["id"]).expect("region body");
|
||||
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)
|
||||
.expect("valid region body");
|
||||
assert_eq!(body, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_update_body_preserves_clears_and_omitted_restrictions() {
|
||||
for expected in [
|
||||
json!({
|
||||
"required_guild_features": [],
|
||||
"allowed_guild_ids": [],
|
||||
"allowed_user_ids": [],
|
||||
"soft_connection_limit": null,
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
}),
|
||||
json!({"is_active": false}),
|
||||
] {
|
||||
let mut params = expected.clone();
|
||||
params["region_id"] = json!("eu");
|
||||
params["server_id"] = json!("primary");
|
||||
let body =
|
||||
voice_request_body(¶ms, &["region_id", "server_id"]).expect("server body");
|
||||
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)
|
||||
.expect("valid server body");
|
||||
assert_eq!(body, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_server_body_keeps_the_server_id_and_rejects_missing_fields() {
|
||||
let expected = json!({
|
||||
"server_id": "primary",
|
||||
"endpoint": "wss://voice.example.com",
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
});
|
||||
let mut params = expected.clone();
|
||||
params["region_id"] = json!("eu");
|
||||
let body = voice_request_body(¶ms, &["region_id"]).expect("server body");
|
||||
validate_against::<generated_types::CreateVoiceServerRequestBody>(&body)
|
||||
.expect("valid server body");
|
||||
assert_eq!(body, expected);
|
||||
assert!(
|
||||
validate_against::<generated_types::CreateVoiceServerRequestBody>(&json!({})).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::client::{AdminApiClient, ApiResultExt},
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
reports::SearchReportsParams,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
@@ -89,22 +92,22 @@ async fn reports_list(
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<i32>().ok());
|
||||
let reports = client
|
||||
.search_reports(
|
||||
search_query.as_deref(),
|
||||
.search_reports(&SearchReportsParams {
|
||||
query: search_query.as_deref(),
|
||||
status,
|
||||
report_type,
|
||||
query.category.as_deref(),
|
||||
query.reporter_id.as_deref(),
|
||||
query.reported_user_id.as_deref(),
|
||||
query.reported_guild_id.as_deref(),
|
||||
query.reported_channel_id.as_deref(),
|
||||
query.guild_context_id.as_deref(),
|
||||
query.resolved_by_admin_id.as_deref(),
|
||||
Some(sort_by),
|
||||
Some(sort_order),
|
||||
category: query.category.as_deref(),
|
||||
reporter_id: query.reporter_id.as_deref(),
|
||||
reported_user_id: query.reported_user_id.as_deref(),
|
||||
reported_guild_id: query.reported_guild_id.as_deref(),
|
||||
reported_channel_id: query.reported_channel_id.as_deref(),
|
||||
guild_context_id: query.guild_context_id.as_deref(),
|
||||
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
|
||||
sort_by: Some(sort_by),
|
||||
sort_order: Some(sort_order),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.log_error("search reports");
|
||||
|
||||
|
||||
@@ -203,14 +203,14 @@ pub async fn instance_config_post(
|
||||
let update = build_media_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"update_voice_noise_suppression" => {
|
||||
let update = build_voice_noise_suppression_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"update_experiment_delivery" => {
|
||||
let update = build_experiment_delivery_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"update_voice_noise_suppression" => match build_voice_noise_suppression_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"test_smtp" => match build_smtp_test_request(&form) {
|
||||
Ok(request) => match client.test_instance_smtp_config(&request).await {
|
||||
Ok(response) if response.ok => FlashData::success("SMTP connection verified"),
|
||||
@@ -412,14 +412,7 @@ fn build_sso_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
allowed_domains: Some(allowed),
|
||||
redirect_uri: None,
|
||||
}),
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
app_public: None,
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,14 +443,7 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
|
||||
.parse_u64("gateway_rollout_max_concurrent_guild_starts"),
|
||||
voice_e2ee_scope,
|
||||
}),
|
||||
sso: None,
|
||||
registration: None,
|
||||
app_public: None,
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,36 +453,42 @@ const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
|
||||
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
|
||||
const EXPERIMENT_DEFAULT_POLL_INTERVAL_SECONDS: u64 = 300;
|
||||
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
|
||||
const EXPERIMENT_DEFAULT_POLL_JITTER_PERCENT: u32 = 15;
|
||||
|
||||
fn parse_clamped_form_number<T>(
|
||||
fn parse_form_number<T>(
|
||||
form: &MultiValueForm,
|
||||
key: &str,
|
||||
empty_value: T,
|
||||
label: &str,
|
||||
min: T,
|
||||
max: T,
|
||||
) -> Option<T>
|
||||
) -> Result<Option<T>, String>
|
||||
where
|
||||
T: std::str::FromStr + Ord,
|
||||
T: std::str::FromStr + Ord + std::fmt::Display,
|
||||
{
|
||||
let raw = form.first(key)?.trim();
|
||||
let value = if raw.is_empty() {
|
||||
empty_value
|
||||
} else {
|
||||
raw.parse::<T>().unwrap_or(empty_value)
|
||||
let Some(raw) = form.first(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Some(value.clamp(min, max))
|
||||
let invalid = || format!("{label} must be a whole number between {min} and {max}");
|
||||
let value = raw.trim().parse::<T>().map_err(|_| invalid())?;
|
||||
if value < min || value > max {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_rollout_salt(form: &MultiValueForm) -> Option<String> {
|
||||
let salt: String = form
|
||||
.clean("voice_ns_rollout_salt")?
|
||||
.chars()
|
||||
.take(VOICE_NS_MAX_ROLLOUT_SALT_CHARS)
|
||||
.collect();
|
||||
clean_string(&salt)
|
||||
fn parse_voice_noise_suppression_rollout_salt(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(raw) = form.first("voice_ns_rollout_salt") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let salt = raw.trim();
|
||||
if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS {
|
||||
return Err(format!(
|
||||
"Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
));
|
||||
}
|
||||
Ok(Some(salt.to_owned()))
|
||||
}
|
||||
|
||||
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
|
||||
@@ -505,135 +497,160 @@ fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_user_ids(value: &str) -> Vec<String> {
|
||||
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
for candidate in value.split([',', '\n', '\r']) {
|
||||
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
|
||||
let candidate = candidate.trim();
|
||||
if !is_voice_noise_suppression_snowflake(candidate) {
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_voice_noise_suppression_snowflake(candidate) {
|
||||
return Err(format!(
|
||||
"{label} entry {} must contain 1 to 20 decimal digits",
|
||||
index + 1
|
||||
));
|
||||
}
|
||||
if ids.iter().any(|existing| existing == candidate) {
|
||||
continue;
|
||||
}
|
||||
ids.push(candidate.to_owned());
|
||||
if ids.len() == VOICE_NS_MAX_TARGETED_USERS {
|
||||
break;
|
||||
return Err(format!(
|
||||
"{label} must contain at most {VOICE_NS_MAX_TARGETED_USERS} unique IDs"
|
||||
));
|
||||
}
|
||||
ids.push(candidate.to_owned());
|
||||
}
|
||||
ids
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_guild_overrides(
|
||||
value: &str,
|
||||
) -> Vec<VoiceNoiseSuppressionGuildOverride> {
|
||||
) -> Result<Vec<VoiceNoiseSuppressionGuildOverride>, String> {
|
||||
let mut overrides: Vec<VoiceNoiseSuppressionGuildOverride> = Vec::new();
|
||||
for line in value.lines() {
|
||||
let Some((guild_id, backend)) = line.split_once('=') else {
|
||||
for (index, line) in value.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
};
|
||||
}
|
||||
let line_number = index + 1;
|
||||
let (guild_id, backend) = line.split_once('=').ok_or_else(|| {
|
||||
format!("Guild overrides line {line_number} must use guild_id=backend")
|
||||
})?;
|
||||
let guild_id = guild_id.trim();
|
||||
if !is_voice_noise_suppression_snowflake(guild_id) {
|
||||
continue;
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
|
||||
));
|
||||
}
|
||||
if overrides
|
||||
let backend = backend.trim().parse().map_err(|_| {
|
||||
format!("Guild overrides line {line_number} must name a supported backend")
|
||||
})?;
|
||||
if let Some(existing) = overrides
|
||||
.iter()
|
||||
.any(|existing| existing.guild_id == guild_id)
|
||||
.find(|existing| existing.guild_id == guild_id)
|
||||
{
|
||||
if existing.backend != backend {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} conflicts with an earlier rule for guild {guild_id}"
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(backend) = NoiseSuppressionBackend::from_value(backend.trim()) else {
|
||||
continue;
|
||||
};
|
||||
if overrides.len() == VOICE_NS_MAX_GUILD_OVERRIDES {
|
||||
return Err(format!(
|
||||
"Guild overrides must contain at most {VOICE_NS_MAX_GUILD_OVERRIDES} unique guilds"
|
||||
));
|
||||
}
|
||||
overrides.push(VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: guild_id.to_owned(),
|
||||
backend,
|
||||
});
|
||||
if overrides.len() == VOICE_NS_MAX_GUILD_OVERRIDES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
overrides
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
fn build_voice_noise_suppression_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let selected =
|
||||
form.list_values_any(&["voice_ns_enabled_backends[]", "voice_ns_enabled_backends"]);
|
||||
fn build_voice_noise_suppression_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
let selected: Vec<NoiseSuppressionBackend> = form
|
||||
.list_values_any(&["voice_ns_enabled_backends[]", "voice_ns_enabled_backends"])
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
value.parse().map_err(|_| {
|
||||
"Enabled backends must name supported noise suppression backends".to_owned()
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
let enabled_backends = NoiseSuppressionBackend::ALL
|
||||
.into_iter()
|
||||
.filter(|backend| selected.iter().any(|value| value == backend.as_str()))
|
||||
.filter(|backend| selected.contains(backend))
|
||||
.collect();
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: None,
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
voice_noise_suppression: Some(VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("voice_ns_enabled")),
|
||||
default_backend: form
|
||||
.first("voice_ns_default_backend")
|
||||
.and_then(NoiseSuppressionBackend::from_value),
|
||||
.map(|value| {
|
||||
value.parse().map_err(|_| {
|
||||
"Default backend must name a supported noise suppression backend".to_owned()
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
enabled_backends: Some(enabled_backends),
|
||||
allow_user_override: Some(form.bool_value("voice_ns_allow_user_override")),
|
||||
rollout_basis_points: parse_clamped_form_number(
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"voice_ns_rollout_basis_points",
|
||||
0,
|
||||
"Rollout basis points",
|
||||
0,
|
||||
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
|
||||
),
|
||||
rollout_salt: parse_voice_noise_suppression_rollout_salt(form),
|
||||
)?,
|
||||
rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?,
|
||||
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
form.first("voice_ns_included_user_ids").unwrap_or_default(),
|
||||
)),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
|
||||
)),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
|
||||
form.first("voice_ns_guild_overrides").unwrap_or_default(),
|
||||
)),
|
||||
)?),
|
||||
stereo_enabled: Some(form.bool_value("voice_ns_stereo_enabled")),
|
||||
suppression_strength: parse_clamped_form_number(
|
||||
suppression_strength: parse_form_number(
|
||||
form,
|
||||
"voice_ns_suppression_strength",
|
||||
0,
|
||||
"Suppression strength",
|
||||
0,
|
||||
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
|
||||
),
|
||||
)?,
|
||||
}),
|
||||
experiment_delivery: None,
|
||||
}
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: None,
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
experiment_delivery: Some(ExperimentDeliveryConfigUpdateRequest {
|
||||
poll_interval_seconds: parse_clamped_form_number(
|
||||
poll_interval_seconds: parse_form_number(
|
||||
form,
|
||||
"experiment_delivery_poll_interval_seconds",
|
||||
EXPERIMENT_DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
"Poll interval",
|
||||
EXPERIMENT_MIN_POLL_INTERVAL_SECONDS,
|
||||
EXPERIMENT_MAX_POLL_INTERVAL_SECONDS,
|
||||
),
|
||||
poll_jitter_percent: parse_clamped_form_number(
|
||||
)?,
|
||||
poll_jitter_percent: parse_form_number(
|
||||
form,
|
||||
"experiment_delivery_poll_jitter_percent",
|
||||
EXPERIMENT_DEFAULT_POLL_JITTER_PERCENT,
|
||||
"Poll jitter",
|
||||
0,
|
||||
EXPERIMENT_MAX_POLL_JITTER_PERCENT,
|
||||
),
|
||||
)?,
|
||||
}),
|
||||
}
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
@@ -644,29 +661,19 @@ fn build_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
|
||||
_ => None,
|
||||
};
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: Some(InstanceRegistrationConfigUpdateRequest {
|
||||
mode,
|
||||
admin_registration_urls_enabled: Some(
|
||||
form.bool_value("admin_registration_urls_enabled"),
|
||||
),
|
||||
}),
|
||||
sso: None,
|
||||
app_public: None,
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let optional = |key: &str| Some(form.clean(key));
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: Some(AppPublicConfigUpdateRequest {
|
||||
branding: Some(AppBrandingConfigUpdateRequest {
|
||||
product_name: form.clean("app_product_name"),
|
||||
@@ -683,20 +690,13 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
|
||||
legal: None,
|
||||
registration: None,
|
||||
}),
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_app_legal_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let optional = |key: &str| Some(form.clean(key));
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: Some(AppPublicConfigUpdateRequest {
|
||||
branding: None,
|
||||
setup: None,
|
||||
@@ -706,19 +706,12 @@ fn build_app_legal_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
|
||||
}),
|
||||
registration: None,
|
||||
}),
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_app_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: Some(AppPublicConfigUpdateRequest {
|
||||
branding: None,
|
||||
setup: None,
|
||||
@@ -727,11 +720,7 @@ fn build_app_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateR
|
||||
collect_date_of_birth: Some(form.bool_value("app_collect_date_of_birth")),
|
||||
}),
|
||||
}),
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,10 +736,6 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let services = build_services_update(form);
|
||||
let deferred_phone_gate = build_deferred_phone_gate_update(form);
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: None,
|
||||
policy: Some(InstancePolicyUpdateRequest {
|
||||
single_community_enabled: None,
|
||||
single_community_name: None,
|
||||
@@ -759,10 +744,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
services,
|
||||
deferred_phone_gate,
|
||||
}),
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,11 +808,6 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
|
||||
_ => None,
|
||||
};
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: None,
|
||||
policy: None,
|
||||
integrations: Some(InstanceIntegrationsUpdateRequest {
|
||||
gif: Some(InstanceGifIntegrationUpdateRequest {
|
||||
klipy_api_key: clean("integration_klipy_api_key"),
|
||||
@@ -871,9 +848,7 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
|
||||
keys: bluesky_keys,
|
||||
}),
|
||||
}),
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -883,12 +858,6 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
.and_then(|value| value.trim().parse::<f64>().ok())
|
||||
};
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: None,
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: Some(InstanceMediaUpdateRequest {
|
||||
attachment_decay: Some(InstanceAttachmentDecayUpdateRequest {
|
||||
enabled: Some(form.bool_value("media_attachment_decay_enabled")),
|
||||
@@ -902,8 +871,7 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
renew_window_days: form.parse_u32("media_attachment_decay_renew_window_days"),
|
||||
}),
|
||||
}),
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -932,10 +900,6 @@ fn build_smtp_test_request(form: &MultiValueForm) -> Result<InstanceEmailSmtpTes
|
||||
|
||||
fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: None,
|
||||
policy: Some(InstancePolicyUpdateRequest {
|
||||
single_community_enabled: Some(enabled),
|
||||
single_community_name: None,
|
||||
@@ -944,10 +908,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
|
||||
services: None,
|
||||
deferred_phone_gate: None,
|
||||
}),
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1273,11 +1234,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_collects_backends_and_clamps_numbers() {
|
||||
fn build_voice_noise_suppression_update_collects_backends_and_validates_numbers() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_enabled=true&voice_ns_allow_user_override=on&voice_ns_default_backend=rnnoise&voice_ns_enabled_backends%5B%5D=deep_filter&voice_ns_enabled_backends%5B%5D=none&voice_ns_enabled_backends%5B%5D=bogus&voice_ns_rollout_basis_points=99999&voice_ns_suppression_strength=250&voice_ns_rollout_salt=%20voice-ns-v2%20",
|
||||
b"voice_ns_enabled=true&voice_ns_allow_user_override=on&voice_ns_default_backend=rnnoise&voice_ns_enabled_backends%5B%5D=deep_filter&voice_ns_enabled_backends%5B%5D=none&voice_ns_enabled_backends%5B%5D=none&voice_ns_rollout_basis_points=10000&voice_ns_suppression_strength=100&voice_ns_rollout_salt=%20voice-ns-v2%20",
|
||||
);
|
||||
let request = build_voice_noise_suppression_update(&form);
|
||||
let request = build_voice_noise_suppression_update(&form).expect("valid form");
|
||||
let update = request
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
@@ -1303,25 +1264,19 @@ mod tests {
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_voice_noise_suppression_update(&form);
|
||||
let update = request
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.enabled, Some(false));
|
||||
assert_eq!(update.allow_user_override, Some(false));
|
||||
assert_eq!(update.stereo_enabled, Some(false));
|
||||
assert_eq!(update.default_backend, None);
|
||||
assert_eq!(update.enabled_backends, Some(Vec::new()));
|
||||
assert_eq!(update.rollout_basis_points, None);
|
||||
assert_eq!(update.rollout_salt, None);
|
||||
assert_eq!(update.included_user_ids, Some(Vec::new()));
|
||||
assert_eq!(update.excluded_user_ids, Some(Vec::new()));
|
||||
assert_eq!(update.guild_overrides, Some(Vec::new()));
|
||||
assert_eq!(update.suppression_strength, None);
|
||||
assert!(request.gateway_rollout.is_none());
|
||||
assert!(request.policy.is_none());
|
||||
assert!(request.media.is_none());
|
||||
assert!(request.experiment_delivery.is_none());
|
||||
let request = build_voice_noise_suppression_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"voice_noise_suppression": {
|
||||
"enabled": false,
|
||||
"allow_user_override": false,
|
||||
"stereo_enabled": false,
|
||||
"enabled_backends": [],
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1330,6 +1285,7 @@ mod tests {
|
||||
b"voice_ns_included_user_ids=1500000000000000001%0A1500000000000000002&voice_ns_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
|
||||
);
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(
|
||||
@@ -1351,7 +1307,8 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 "),
|
||||
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec![
|
||||
"1".to_owned(),
|
||||
"2".to_owned(),
|
||||
@@ -1365,111 +1322,167 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids("20,10,20,10,30"),
|
||||
parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
assert!(
|
||||
for value in [
|
||||
"abc",
|
||||
"12a",
|
||||
"-1",
|
||||
"1.0",
|
||||
"999999999999999999999",
|
||||
"<script>",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(
|
||||
"abc,12a,-1,1.0, ,999999999999999999999,<script>"
|
||||
&format!("123,{value}"),
|
||||
"Included user IDs"
|
||||
)
|
||||
.is_empty()
|
||||
.expect_err("invalid ID"),
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_truncates_at_cap() {
|
||||
let value = (0..1_200)
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_exceeding_the_cap() {
|
||||
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ids = parse_voice_noise_suppression_user_ids(&value);
|
||||
let ids =
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_skips_malformed_lines() {
|
||||
let overrides = parse_voice_noise_suppression_guild_overrides(
|
||||
" 1600000000000000001 = rnnoise \n1600000000000000002\n=gate\nnot-a-guild=gate\n1600000000000000003=unknown_backend\n1600000000000000004=deep_filter\n",
|
||||
);
|
||||
assert_eq!(
|
||||
overrides,
|
||||
vec![
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
},
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000004".to_owned(),
|
||||
backend: NoiseSuppressionBackend::DeepFilter,
|
||||
},
|
||||
]
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
.expect_err("too many IDs"),
|
||||
"Included user IDs must contain at most 1000 unique IDs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_treats_blank_numbers_as_explicit_values() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_rollout_basis_points=&voice_ns_suppression_strength=%20%20",
|
||||
fn parse_voice_noise_suppression_guild_overrides_rejects_malformed_lines() {
|
||||
for (line, message) in [
|
||||
("456", "Guild overrides line 3 must use guild_id=backend"),
|
||||
(
|
||||
"=gate",
|
||||
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"not-a-guild=gate",
|
||||
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"999999999999999999999=gate",
|
||||
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"456=unknown_backend",
|
||||
"Guild overrides line 3 must name a supported backend",
|
||||
),
|
||||
(
|
||||
"456=",
|
||||
"Guild overrides line 3 must name a supported backend",
|
||||
),
|
||||
(
|
||||
"123=gate",
|
||||
"Guild overrides line 3 conflicts with an earlier rule for guild 123",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_guild_overrides(&format!("\n123=rnnoise\n{line}"))
|
||||
.expect_err("invalid guild rule"),
|
||||
message,
|
||||
"{line}"
|
||||
);
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_basis_points, Some(0));
|
||||
assert_eq!(update.suppression_strength, Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_treats_unparseable_numbers_as_explicit_values() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_rollout_basis_points=abc&voice_ns_suppression_strength=-5",
|
||||
fn build_voice_noise_suppression_update_rejects_invalid_numbers() {
|
||||
for (key, message, above_max) in [
|
||||
(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
"10001",
|
||||
),
|
||||
(
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression strength must be a whole number between 0 and 100",
|
||||
"101",
|
||||
),
|
||||
] {
|
||||
for value in [
|
||||
"",
|
||||
"%20%20",
|
||||
"abc",
|
||||
"-1",
|
||||
"1.5",
|
||||
"9999999999999999999999999",
|
||||
above_max,
|
||||
] {
|
||||
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_noise_suppression_update(&form).expect_err("invalid number"),
|
||||
message,
|
||||
"{key}={value}"
|
||||
);
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_basis_points, Some(0));
|
||||
assert_eq!(update.suppression_strength, Some(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_accepts_padded_numbers() {
|
||||
let form = MultiValueForm::parse(b"voice_ns_rollout_basis_points=%20250%20");
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_basis_points, Some(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_truncates_an_overlong_rollout_salt() {
|
||||
let salt = "\u{e9}".repeat(80);
|
||||
fn build_voice_noise_suppression_update_rejects_invalid_rollout_salts() {
|
||||
for salt in [
|
||||
String::new(),
|
||||
" ".to_owned(),
|
||||
"é".repeat(65),
|
||||
"🎲".repeat(33),
|
||||
] {
|
||||
let form = MultiValueForm::parse(format!("voice_ns_rollout_salt={salt}").as_bytes());
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
let salt = update.rollout_salt.expect("rollout salt");
|
||||
assert_eq!(salt.chars().count(), VOICE_NS_MAX_ROLLOUT_SALT_CHARS);
|
||||
assert_eq!(salt, "\u{e9}".repeat(VOICE_NS_MAX_ROLLOUT_SALT_CHARS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_never_sends_an_empty_rollout_salt() {
|
||||
let form = MultiValueForm::parse(b"voice_ns_rollout_salt=%20%20%20");
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_salt, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_dedupes_keeping_the_first_backend() {
|
||||
let overrides = parse_voice_noise_suppression_guild_overrides(
|
||||
"1600000000000000001=rnnoise\n1600000000000000001=gate\n1600000000000000002=speex\n1600000000000000001=deep_filter\n",
|
||||
assert_eq!(
|
||||
build_voice_noise_suppression_update(&form).expect_err("invalid salt"),
|
||||
"Rollout salt must be between 1 and 64 characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_preserves_valid_rollout_salts() {
|
||||
for salt in ["x".to_owned(), "é".repeat(64), "🎲".repeat(32)] {
|
||||
let form =
|
||||
MultiValueForm::parse(format!("voice_ns_rollout_salt=%20{salt}%20").as_bytes());
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_salt, Some(salt));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_normalizes_identical_rules() {
|
||||
let overrides = parse_voice_noise_suppression_guild_overrides(
|
||||
" 1600000000000000001 = rnnoise \n\n1600000000000000001=rnnoise\n1600000000000000002=speex\n",
|
||||
).expect("valid guild rules");
|
||||
assert_eq!(
|
||||
overrides,
|
||||
vec![
|
||||
@@ -1486,98 +1499,118 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_truncates_at_cap() {
|
||||
let value = (0..300)
|
||||
fn parse_voice_noise_suppression_guild_overrides_rejects_exceeding_the_cap() {
|
||||
let value = (0..VOICE_NS_MAX_GUILD_OVERRIDES)
|
||||
.map(|index| format!("{index}=gate"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let overrides = parse_voice_noise_suppression_guild_overrides(&value);
|
||||
let overrides =
|
||||
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n199=gate"))
|
||||
.expect("valid guild rules at cap");
|
||||
assert_eq!(overrides.len(), VOICE_NS_MAX_GUILD_OVERRIDES);
|
||||
assert_eq!(
|
||||
overrides.last().map(|entry| entry.guild_id.as_str()),
|
||||
Some("199")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n200=gate"))
|
||||
.expect_err("too many guild rules"),
|
||||
"Guild overrides must contain at most 200 unique guilds"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_reports_invalid_targeting_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"voice_ns_default_backend=unknown",
|
||||
"Default backend must name a supported noise suppression backend",
|
||||
),
|
||||
(
|
||||
"voice_ns_default_backend=",
|
||||
"Default backend must name a supported noise suppression backend",
|
||||
),
|
||||
(
|
||||
"voice_ns_enabled_backends%5B%5D=rnnoise&voice_ns_enabled_backends%5B%5D=unknown",
|
||||
"Enabled backends must name supported noise suppression backends",
|
||||
),
|
||||
(
|
||||
"voice_ns_included_user_ids=123%2Cinvalid",
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"voice_ns_excluded_user_ids=123%2Cinvalid",
|
||||
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"voice_ns_guild_overrides=123%3Dgate%0A123%3Drnnoise",
|
||||
"Guild overrides line 2 conflicts with an earlier rule for guild 123",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_noise_suppression_update(&form).expect_err("invalid targeting"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_experiment_delivery_update(&form);
|
||||
let update = request
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
assert_eq!(update.poll_interval_seconds, None);
|
||||
assert_eq!(update.poll_jitter_percent, None);
|
||||
assert!(request.voice_noise_suppression.is_none());
|
||||
assert!(request.gateway_rollout.is_none());
|
||||
assert!(request.policy.is_none());
|
||||
assert!(request.media.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_treats_blank_numbers_as_explicit_defaults() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"experiment_delivery_poll_interval_seconds=&experiment_delivery_poll_jitter_percent=%20%20",
|
||||
);
|
||||
let update = build_experiment_delivery_update(&form)
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
let request = build_experiment_delivery_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
update.poll_interval_seconds,
|
||||
Some(EXPERIMENT_DEFAULT_POLL_INTERVAL_SECONDS)
|
||||
);
|
||||
assert_eq!(
|
||||
update.poll_jitter_percent,
|
||||
Some(EXPERIMENT_DEFAULT_POLL_JITTER_PERCENT)
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"experiment_delivery": {}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_treats_unparseable_numbers_as_explicit_defaults() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"experiment_delivery_poll_interval_seconds=12.5&experiment_delivery_poll_jitter_percent=abc",
|
||||
);
|
||||
let update = build_experiment_delivery_update(&form)
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
fn build_experiment_delivery_update_rejects_invalid_numbers() {
|
||||
for (key, message, below_min, above_max) in [
|
||||
(
|
||||
"experiment_delivery_poll_interval_seconds",
|
||||
"Poll interval must be a whole number between 60 and 86400",
|
||||
"59",
|
||||
"86401",
|
||||
),
|
||||
(
|
||||
"experiment_delivery_poll_jitter_percent",
|
||||
"Poll jitter must be a whole number between 0 and 50",
|
||||
"-1",
|
||||
"51",
|
||||
),
|
||||
] {
|
||||
for value in [
|
||||
"",
|
||||
"%20%20",
|
||||
"abc",
|
||||
"-1",
|
||||
"1.5",
|
||||
"9999999999999999999999999",
|
||||
below_min,
|
||||
above_max,
|
||||
] {
|
||||
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
|
||||
assert_eq!(
|
||||
update.poll_interval_seconds,
|
||||
Some(EXPERIMENT_DEFAULT_POLL_INTERVAL_SECONDS)
|
||||
);
|
||||
assert_eq!(
|
||||
update.poll_jitter_percent,
|
||||
Some(EXPERIMENT_DEFAULT_POLL_JITTER_PERCENT)
|
||||
build_experiment_delivery_update(&form).expect_err("invalid number"),
|
||||
message,
|
||||
"{key}={value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_clamps_numbers_to_their_bounds() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"experiment_delivery_poll_interval_seconds=5&experiment_delivery_poll_jitter_percent=90",
|
||||
);
|
||||
let update = build_experiment_delivery_update(&form)
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
fn build_experiment_delivery_update_accepts_inclusive_bounds() {
|
||||
for (interval, jitter) in [(60, 0), (86_400, 50)] {
|
||||
let form = MultiValueForm::parse(format!("experiment_delivery_poll_interval_seconds={interval}&experiment_delivery_poll_jitter_percent={jitter}").as_bytes());
|
||||
let request = build_experiment_delivery_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
update.poll_interval_seconds,
|
||||
Some(EXPERIMENT_MIN_POLL_INTERVAL_SECONDS)
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"experiment_delivery": {"poll_interval_seconds": interval, "poll_jitter_percent": jitter}})
|
||||
);
|
||||
assert_eq!(
|
||||
update.poll_jitter_percent,
|
||||
Some(EXPERIMENT_MAX_POLL_JITTER_PERCENT)
|
||||
);
|
||||
|
||||
let form = MultiValueForm::parse(
|
||||
b"experiment_delivery_poll_interval_seconds=999999&experiment_delivery_poll_jitter_percent=0",
|
||||
);
|
||||
let update = build_experiment_delivery_update(&form)
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
assert_eq!(
|
||||
update.poll_interval_seconds,
|
||||
Some(EXPERIMENT_MAX_POLL_INTERVAL_SECONDS)
|
||||
);
|
||||
assert_eq!(update.poll_jitter_percent, Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1586,6 +1619,7 @@ mod tests {
|
||||
b"experiment_delivery_poll_interval_seconds=%20900%20&experiment_delivery_poll_jitter_percent=%2025%20",
|
||||
);
|
||||
let update = build_experiment_delivery_update(&form)
|
||||
.expect("valid form")
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
assert_eq!(update.poll_interval_seconds, Some(900));
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub filename: String,
|
||||
pub nsfw: Option<bool>,
|
||||
pub content_type: Option<String>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub size: Option<u64>,
|
||||
pub ncmec_status: String,
|
||||
pub ncmec_report_id: Option<String>,
|
||||
pub ncmec_failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Message {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub timestamp: String,
|
||||
pub author_id: String,
|
||||
pub author_username: String,
|
||||
pub author_global_name: Option<String>,
|
||||
pub author_discriminator: String,
|
||||
pub author_avatar: Option<String>,
|
||||
pub channel_id: String,
|
||||
pub channel_nsfw: Option<bool>,
|
||||
pub channel_content_warning_level: Option<i32>,
|
||||
pub channel_content_warning_text: Option<String>,
|
||||
pub guild_nsfw: Option<bool>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
|
||||
pub fn ordered_messages(values: &[Value]) -> Vec<Message> {
|
||||
let mut messages: Vec<Message> = values.iter().map(message_from_value).collect();
|
||||
messages.sort_by(compare_message_ids);
|
||||
messages
|
||||
}
|
||||
|
||||
fn message_from_value(value: &Value) -> Message {
|
||||
let attachments = value["attachments"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(attachment_from_value)
|
||||
.collect();
|
||||
Message {
|
||||
id: value_id(&value["id"]).unwrap_or_default(),
|
||||
content: value["content"].as_str().unwrap_or("").to_owned(),
|
||||
timestamp: value["timestamp"].as_str().unwrap_or("").to_owned(),
|
||||
author_id: value_id(&value["author_id"]).unwrap_or_default(),
|
||||
author_username: value["author_username"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown")
|
||||
.to_owned(),
|
||||
author_global_name: value["author_global_name"].as_str().map(ToOwned::to_owned),
|
||||
author_discriminator: value_id(&value["author_discriminator"])
|
||||
.unwrap_or_else(|| "0000".to_owned()),
|
||||
author_avatar: value["author_avatar"].as_str().map(ToOwned::to_owned),
|
||||
channel_id: value_id(&value["channel_id"]).unwrap_or_default(),
|
||||
channel_nsfw: value["channel_nsfw"].as_bool(),
|
||||
channel_content_warning_level: value["channel_content_warning_level"]
|
||||
.as_i64()
|
||||
.map(|n| n as i32),
|
||||
channel_content_warning_text: value["channel_content_warning_text"]
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned),
|
||||
guild_nsfw: value["guild_nsfw"].as_bool(),
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment_from_value(value: &Value) -> Attachment {
|
||||
Attachment {
|
||||
id: value_id(&value["id"]).unwrap_or_default(),
|
||||
url: value["url"].as_str().unwrap_or("").to_owned(),
|
||||
filename: value["filename"].as_str().unwrap_or("").to_owned(),
|
||||
nsfw: value["nsfw"].as_bool(),
|
||||
content_type: value["content_type"].as_str().map(ToOwned::to_owned),
|
||||
width: value["width"].as_u64().map(|n| n as u32),
|
||||
height: value["height"].as_u64().map(|n| n as u32),
|
||||
size: value["size"].as_u64(),
|
||||
ncmec_status: value["ncmec_status"]
|
||||
.as_str()
|
||||
.unwrap_or("not_submitted")
|
||||
.to_owned(),
|
||||
ncmec_report_id: value["ncmec_report_id"].as_str().map(ToOwned::to_owned),
|
||||
ncmec_failure_reason: value["ncmec_failure_reason"]
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn value_id(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
|
||||
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
|
||||
(Ok(l), Ok(r)) => l.cmp(&r),
|
||||
_ => left.id.cmp(&right.id),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn maps_message_and_attachment_fields() {
|
||||
let value = json!({
|
||||
"id": "9007199254740993",
|
||||
"content": "Message content",
|
||||
"timestamp": "2026-09-11T12:00:00Z",
|
||||
"author_id": 42,
|
||||
"author_username": "alice",
|
||||
"author_global_name": "Alice",
|
||||
"author_discriminator": 1234,
|
||||
"author_avatar": "avatar-hash",
|
||||
"channel_id": "100",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": 2,
|
||||
"channel_content_warning_text": "Content warning",
|
||||
"guild_nsfw": true,
|
||||
"attachments": [{
|
||||
"id": 9007199254740993_u64,
|
||||
"url": "https://cdn.example.com/image.png",
|
||||
"filename": "image.png",
|
||||
"nsfw": true,
|
||||
"content_type": "image/png",
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"size": 4096,
|
||||
"ncmec_status": "submitted",
|
||||
"ncmec_report_id": "report-id",
|
||||
"ncmec_failure_reason": "previous failure"
|
||||
}]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
message_from_value(&value),
|
||||
Message {
|
||||
id: "9007199254740993".into(),
|
||||
content: "Message content".into(),
|
||||
timestamp: "2026-09-11T12:00:00Z".into(),
|
||||
author_id: "42".into(),
|
||||
author_username: "alice".into(),
|
||||
author_global_name: Some("Alice".into()),
|
||||
author_discriminator: "1234".into(),
|
||||
author_avatar: Some("avatar-hash".into()),
|
||||
channel_id: "100".into(),
|
||||
channel_nsfw: Some(false),
|
||||
channel_content_warning_level: Some(2),
|
||||
channel_content_warning_text: Some("Content warning".into()),
|
||||
guild_nsfw: Some(true),
|
||||
attachments: vec![Attachment {
|
||||
id: "9007199254740993".into(),
|
||||
url: "https://cdn.example.com/image.png".into(),
|
||||
filename: "image.png".into(),
|
||||
nsfw: Some(true),
|
||||
content_type: Some("image/png".into()),
|
||||
width: Some(640),
|
||||
height: Some(480),
|
||||
size: Some(4096),
|
||||
ncmec_status: "submitted".into(),
|
||||
ncmec_report_id: Some("report-id".into()),
|
||||
ncmec_failure_reason: Some("previous failure".into()),
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_display_defaults_for_incomplete_snapshots() {
|
||||
let message = message_from_value(&json!({"attachments": [{}]}));
|
||||
|
||||
assert_eq!(message.author_username, "Unknown");
|
||||
assert_eq!(message.author_discriminator, "0000");
|
||||
assert_eq!(message.content, "");
|
||||
assert_eq!(message.author_global_name, None);
|
||||
assert_eq!(message.channel_nsfw, None);
|
||||
assert_eq!(
|
||||
message.attachments,
|
||||
vec![Attachment {
|
||||
id: String::new(),
|
||||
url: String::new(),
|
||||
filename: String::new(),
|
||||
nsfw: None,
|
||||
content_type: None,
|
||||
width: None,
|
||||
height: None,
|
||||
size: None,
|
||||
ncmec_status: "not_submitted".into(),
|
||||
ncmec_report_id: None,
|
||||
ncmec_failure_reason: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orders_string_and_numeric_snowflakes_without_rounding() {
|
||||
let values = vec![
|
||||
json!({"id": "9007199254740993"}),
|
||||
json!({"id": "10"}),
|
||||
json!({"id": 2}),
|
||||
json!({"id": 9007199254740992_u64}),
|
||||
];
|
||||
let messages = ordered_messages(&values);
|
||||
let ids: Vec<&str> = messages.iter().map(|message| message.id.as_str()).collect();
|
||||
|
||||
assert_eq!(ids, ["2", "10", "9007199254740992", "9007199254740993"]);
|
||||
assert_eq!(values[0]["id"], "9007199254740993");
|
||||
assert!(ordered_messages(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_message_order_when_ids_are_equal() {
|
||||
let values = [
|
||||
json!({"id": "10", "content": "first"}),
|
||||
json!({"id": 10, "content": "second"}),
|
||||
];
|
||||
let messages = ordered_messages(&values);
|
||||
|
||||
assert_eq!(messages[0].content, "first");
|
||||
assert_eq!(messages[1].content, "second");
|
||||
}
|
||||
}
|
||||
@@ -9,36 +9,7 @@ use super::user_display::format_user_display;
|
||||
use crate::config::AdminConfig;
|
||||
use crate::routes::auth::json_string;
|
||||
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub filename: String,
|
||||
pub nsfw: Option<bool>,
|
||||
pub content_type: Option<String>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub size: Option<u64>,
|
||||
pub ncmec_status: String,
|
||||
pub ncmec_report_id: Option<String>,
|
||||
pub ncmec_failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Message {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub timestamp: String,
|
||||
pub author_id: String,
|
||||
pub author_username: String,
|
||||
pub author_global_name: Option<String>,
|
||||
pub author_discriminator: String,
|
||||
pub author_avatar: Option<String>,
|
||||
pub channel_id: String,
|
||||
pub channel_nsfw: Option<bool>,
|
||||
pub channel_content_warning_level: Option<i32>,
|
||||
pub channel_content_warning_text: Option<String>,
|
||||
pub guild_nsfw: Option<bool>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
use super::message_data::{Attachment, Message};
|
||||
|
||||
fn is_image(att: &Attachment) -> bool {
|
||||
att.content_type
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod error_display;
|
||||
pub mod form;
|
||||
pub mod icons;
|
||||
pub mod media;
|
||||
pub mod message_data;
|
||||
pub mod message_list;
|
||||
pub mod nsfw_indicators;
|
||||
pub mod page_container;
|
||||
|
||||
@@ -49,7 +49,7 @@ fn entry_count_hint(count: usize, cap: usize) -> Markup {
|
||||
p class="text-xs text-neutral-500" {
|
||||
(count) " of " (cap) " stored"
|
||||
@if count >= cap {
|
||||
" (at the cap; extra lines are dropped on save)"
|
||||
" (at the cap; remove an entry before adding another)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -978,16 +978,18 @@ fn voice_noise_suppression_section(
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let backend_options = NoiseSuppressionBackend::ALL
|
||||
let backend_labels =
|
||||
NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label()));
|
||||
let backend_options = backend_labels
|
||||
.iter()
|
||||
.map(|backend| (backend.as_str(), backend.label()))
|
||||
.map(|(value, label)| (value.as_str(), *label))
|
||||
.collect::<Vec<_>>();
|
||||
let included_user_ids = voice_noise_suppression.included_user_ids.join("\n");
|
||||
let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n");
|
||||
let guild_overrides = voice_noise_suppression
|
||||
.guild_overrides
|
||||
.iter()
|
||||
.map(|entry| format!("{}={}", entry.guild_id, entry.backend.as_str()))
|
||||
.map(|entry| format!("{}={}", entry.guild_id, entry.backend))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
section_card_with_description(
|
||||
@@ -1025,17 +1027,18 @@ fn voice_noise_suppression_section(
|
||||
"voice_ns_default_backend",
|
||||
"Default Backend",
|
||||
&backend_options,
|
||||
voice_noise_suppression.default_backend.as_str(),
|
||||
&voice_noise_suppression.default_backend.to_string(),
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"The backend handed to every targeted user. A default that is not ticked \
|
||||
below is treated as unavailable and nobody is targeted."
|
||||
"The backend assigned by always-on user rules and the canary. A default \
|
||||
that is not ticked below is unavailable, but per-guild overrides can \
|
||||
still target users."
|
||||
}
|
||||
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
|
||||
@for backend in NoiseSuppressionBackend::ALL {
|
||||
(checkbox(
|
||||
"voice_ns_enabled_backends[]",
|
||||
backend.as_str(),
|
||||
&backend.to_string(),
|
||||
backend.label(),
|
||||
voice_noise_suppression.enabled_backends.contains(&backend),
|
||||
true,
|
||||
@@ -1094,8 +1097,9 @@ fn voice_noise_suppression_section(
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. Anything that is not a plain \
|
||||
numeric ID is dropped."
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save; blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
@@ -1132,10 +1136,11 @@ fn voice_noise_suppression_section(
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One per line as guild_id=backend, first line per guild wins. Targeted \
|
||||
users in that guild get that backend instead of the default. Lines \
|
||||
that are not a numeric guild ID and a ticked backend are dropped \
|
||||
without failing the save."
|
||||
"One per line as guild_id=backend. A guild \
|
||||
rule targets callers even outside the canary. Always-on user rules \
|
||||
take precedence, and excluded users stay off. Invalid lines and \
|
||||
conflicting rules for the same guild prevent the save. \
|
||||
Unticked backends stay stored but are inactive."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ use crate::{
|
||||
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, form_field_group,
|
||||
submit_button,
|
||||
},
|
||||
message_list::{Attachment, Message, message_deletion_script, message_list},
|
||||
message_data::{Message, ordered_messages, value_id},
|
||||
message_list::{message_deletion_script, message_list},
|
||||
page_container::{card, page_header},
|
||||
},
|
||||
layout::LayoutOptions,
|
||||
@@ -19,7 +20,6 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
use serde_json::Value;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
const MESSAGE_BROWSE_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
@@ -263,7 +263,7 @@ pub fn browse_messages_fragment(
|
||||
show_delete: bool,
|
||||
highlight_message_id: Option<&str>,
|
||||
) -> Markup {
|
||||
let messages = ordered_messages(result);
|
||||
let messages = response_messages(result);
|
||||
let has_more = result
|
||||
.get("has_more")
|
||||
.and_then(Value::as_bool)
|
||||
@@ -295,7 +295,7 @@ fn browse_result_card(
|
||||
csrf_token: &str,
|
||||
context_limit: u32,
|
||||
) -> Markup {
|
||||
let messages = ordered_messages(result);
|
||||
let messages = response_messages(result);
|
||||
let has_more = result
|
||||
.get("has_more")
|
||||
.and_then(Value::as_bool)
|
||||
@@ -345,7 +345,7 @@ fn search_result_card(
|
||||
query_text: &str,
|
||||
show_delete: bool,
|
||||
) -> Markup {
|
||||
let messages = ordered_messages(result);
|
||||
let messages = response_messages(result);
|
||||
let total = result
|
||||
.get("total")
|
||||
.and_then(Value::as_u64)
|
||||
@@ -382,7 +382,7 @@ fn lookup_result_card(
|
||||
show_delete: bool,
|
||||
context_limit: u32,
|
||||
) -> Markup {
|
||||
let messages = ordered_messages(result);
|
||||
let messages = response_messages(result);
|
||||
let channel_id = messages
|
||||
.first()
|
||||
.map(|m| m.channel_id.as_str())
|
||||
@@ -508,130 +508,12 @@ fn empty_state(text: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn ordered_messages(result: &Value) -> Vec<Message> {
|
||||
let mut messages: Vec<Message> = result
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(message_from_value)
|
||||
.collect();
|
||||
messages.sort_by(compare_message_ids);
|
||||
messages
|
||||
}
|
||||
|
||||
fn message_from_value(value: &Value) -> Message {
|
||||
let attachments = value
|
||||
.get("attachments")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(attachment_from_value)
|
||||
.collect();
|
||||
Message {
|
||||
id: value.get("id").and_then(value_id).unwrap_or_default(),
|
||||
content: value
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
timestamp: value
|
||||
.get("timestamp")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
author_id: value
|
||||
.get("author_id")
|
||||
.and_then(value_id)
|
||||
.unwrap_or_default(),
|
||||
author_username: value
|
||||
.get("author_username")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Unknown")
|
||||
.to_owned(),
|
||||
author_global_name: value
|
||||
.get("author_global_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
author_discriminator: value
|
||||
.get("author_discriminator")
|
||||
.and_then(value_id)
|
||||
.unwrap_or_else(|| "0000".to_owned()),
|
||||
author_avatar: value
|
||||
.get("author_avatar")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
channel_id: value
|
||||
.get("channel_id")
|
||||
.and_then(value_id)
|
||||
.unwrap_or_default(),
|
||||
channel_nsfw: value.get("channel_nsfw").and_then(Value::as_bool),
|
||||
channel_content_warning_level: value
|
||||
.get("channel_content_warning_level")
|
||||
.and_then(Value::as_i64)
|
||||
.map(|n| n as i32),
|
||||
channel_content_warning_text: value
|
||||
.get("channel_content_warning_text")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
guild_nsfw: value.get("guild_nsfw").and_then(Value::as_bool),
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment_from_value(value: &Value) -> Attachment {
|
||||
Attachment {
|
||||
id: value.get("id").and_then(value_id).unwrap_or_default(),
|
||||
url: value
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
filename: value
|
||||
.get("filename")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
nsfw: value.get("nsfw").and_then(Value::as_bool),
|
||||
content_type: value
|
||||
.get("content_type")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
width: value.get("width").and_then(Value::as_u64).map(|n| n as u32),
|
||||
height: value
|
||||
.get("height")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n as u32),
|
||||
size: value.get("size").and_then(Value::as_u64),
|
||||
ncmec_status: value
|
||||
.get("ncmec_status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("not_submitted")
|
||||
.to_owned(),
|
||||
ncmec_report_id: value
|
||||
.get("ncmec_report_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
ncmec_failure_reason: value
|
||||
.get("ncmec_failure_reason")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_id(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
|
||||
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
|
||||
(Ok(l), Ok(r)) => l.cmp(&r),
|
||||
_ => left.id.cmp(&right.id),
|
||||
}
|
||||
fn response_messages(result: &Value) -> Vec<Message> {
|
||||
let values = result["messages"]
|
||||
.as_array()
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default();
|
||||
ordered_messages(values)
|
||||
}
|
||||
|
||||
fn browse_channel_form(config: &AdminConfig, csrf_token: &str, prefill: Option<&str>) -> Markup {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::types::ReportEntry,
|
||||
@@ -13,7 +11,8 @@ use crate::{
|
||||
data_field::{data_field, data_field_link_mono, data_field_mono, data_field_text},
|
||||
form::csrf_input,
|
||||
media::{guild_icon_url, initials, user_avatar_url},
|
||||
message_list::{Attachment, Message, message_deletion_script, message_list},
|
||||
message_data::ordered_messages,
|
||||
message_list::{message_deletion_script, message_list},
|
||||
nsfw_indicators::{
|
||||
adult_content_badge, channel_nsfw_state_badge, content_warning_badge,
|
||||
},
|
||||
@@ -26,7 +25,6 @@ use crate::{
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
use serde_json::Value;
|
||||
|
||||
fn status_badge(status: i32) -> Markup {
|
||||
let (label, variant) = match status {
|
||||
@@ -534,123 +532,3 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn ordered_messages(values: &[Value]) -> Vec<Message> {
|
||||
let mut messages: Vec<Message> = values.iter().map(message_from_value).collect();
|
||||
messages.sort_by(compare_message_ids);
|
||||
messages
|
||||
}
|
||||
|
||||
fn message_from_value(value: &Value) -> Message {
|
||||
let attachments = value
|
||||
.get("attachments")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(attachment_from_value)
|
||||
.collect();
|
||||
Message {
|
||||
id: value.get("id").and_then(value_id).unwrap_or_default(),
|
||||
content: value
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
timestamp: value
|
||||
.get("timestamp")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
author_id: value
|
||||
.get("author_id")
|
||||
.and_then(value_id)
|
||||
.unwrap_or_default(),
|
||||
author_username: value
|
||||
.get("author_username")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Unknown")
|
||||
.to_owned(),
|
||||
author_global_name: value
|
||||
.get("author_global_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
author_discriminator: value
|
||||
.get("author_discriminator")
|
||||
.and_then(value_id)
|
||||
.unwrap_or_else(|| "0000".to_owned()),
|
||||
author_avatar: value
|
||||
.get("author_avatar")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
channel_id: value
|
||||
.get("channel_id")
|
||||
.and_then(value_id)
|
||||
.unwrap_or_default(),
|
||||
channel_nsfw: value.get("channel_nsfw").and_then(Value::as_bool),
|
||||
channel_content_warning_level: value
|
||||
.get("channel_content_warning_level")
|
||||
.and_then(Value::as_i64)
|
||||
.map(|n| n as i32),
|
||||
channel_content_warning_text: value
|
||||
.get("channel_content_warning_text")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
guild_nsfw: value.get("guild_nsfw").and_then(Value::as_bool),
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment_from_value(value: &Value) -> Attachment {
|
||||
Attachment {
|
||||
id: value.get("id").and_then(value_id).unwrap_or_default(),
|
||||
url: value
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
filename: value
|
||||
.get("filename")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
nsfw: value.get("nsfw").and_then(Value::as_bool),
|
||||
content_type: value
|
||||
.get("content_type")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
width: value.get("width").and_then(Value::as_u64).map(|n| n as u32),
|
||||
height: value
|
||||
.get("height")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n as u32),
|
||||
size: value.get("size").and_then(Value::as_u64),
|
||||
ncmec_status: value
|
||||
.get("ncmec_status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("not_submitted")
|
||||
.to_owned(),
|
||||
ncmec_report_id: value
|
||||
.get("ncmec_report_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
ncmec_failure_reason: value
|
||||
.get("ncmec_failure_reason")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_id(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
|
||||
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
|
||||
(Ok(l), Ok(r)) => l.cmp(&r),
|
||||
_ => left.id.cmp(&right.id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use fluxer_admin::{
|
||||
api::{generated::types as generated_types, types::LookupGuildResponse},
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
session,
|
||||
@@ -905,8 +906,8 @@ fn user(id: &str, username: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn searched_guild() -> Value {
|
||||
json!({
|
||||
fn searched_guild() -> generated_types::GuildAdminResponse {
|
||||
serde_json::from_value(json!({
|
||||
"id": "1600000000000000001",
|
||||
"name": "Searched Guild",
|
||||
"icon": null,
|
||||
@@ -919,15 +920,14 @@ fn searched_guild() -> Value {
|
||||
"features": ["COMMUNITY"],
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": null,
|
||||
"content_warning_text": null,
|
||||
"description": "Guild used by HTMX acceptance tests.",
|
||||
"vanity_url_code": null
|
||||
})
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null
|
||||
}))
|
||||
.expect("guild search fixture must match the generated response contract")
|
||||
}
|
||||
|
||||
fn searched_guild_detail() -> Value {
|
||||
json!({
|
||||
fn searched_guild_detail() -> generated_types::LookupGuildResponseGuild {
|
||||
serde_json::from_value(json!({
|
||||
"id": "1600000000000000001",
|
||||
"owner_id": "1500000000000000001",
|
||||
"owner_username": "SearchedUser",
|
||||
@@ -944,7 +944,7 @@ fn searched_guild_detail() -> Value {
|
||||
"mfa_level": 0,
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": null,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null,
|
||||
"explicit_content_filter": 0,
|
||||
"default_message_notifications": 0,
|
||||
@@ -956,9 +956,23 @@ fn searched_guild_detail() -> Value {
|
||||
"disabled_operations": 0,
|
||||
"member_count": 12,
|
||||
"channels": [],
|
||||
"roles": [],
|
||||
"description": "Guild used by HTMX acceptance tests."
|
||||
})
|
||||
"roles": []
|
||||
}))
|
||||
.expect("guild detail fixture must match the generated response contract")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_fixtures_match_generated_response_contracts() {
|
||||
let search = searched_guild();
|
||||
assert_eq!(search.name, "Searched Guild");
|
||||
assert_eq!(*search.member_count, 12);
|
||||
|
||||
let response: LookupGuildResponse =
|
||||
serde_json::from_value(json!({"guild": searched_guild_detail()})).unwrap();
|
||||
let detail = response.guild.unwrap();
|
||||
assert_eq!(detail.name, "Searched Guild");
|
||||
assert_eq!(detail.id, "1600000000000000001");
|
||||
assert_eq!(detail.member_count, 12);
|
||||
}
|
||||
|
||||
fn searched_application() -> Value {
|
||||
|
||||
@@ -22,7 +22,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @fluxer/config run --if-present generate
|
||||
RUN pnpm --filter fluxer_api run build
|
||||
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allowUnusedPatches=true /out
|
||||
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"esbuild": "catalog:",
|
||||
"msw": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
|
||||
Vendored
-1
@@ -17,7 +17,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,18 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
resolve: {tsconfigPaths: true},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/nodemailer": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
root: process.cwd(),
|
||||
plugins: [tsconfigPaths()],
|
||||
resolve: {tsconfigPaths: true},
|
||||
cacheDir: './node_modules/.vitest',
|
||||
test: {
|
||||
globals: true,
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ function createBlockedRequestError(url: URL, context: RequestUrlValidationContex
|
||||
}
|
||||
|
||||
async function defaultLookupHost(hostname: string): Promise<Array<string>> {
|
||||
const addresses = await dns.promises.lookup(hostname, {all: true, verbatim: true});
|
||||
const addresses = await dns.promises.lookup(hostname, {all: true, order: 'verbatim'});
|
||||
return addresses.map((addressEntry) => addressEntry.address);
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ function deduplicateAddresses(addresses: Array<string>): Array<string> {
|
||||
|
||||
function createBlocklistDispatcher(allowPrivateAddresses: boolean): NonNullable<RequestInit['dispatcher']> {
|
||||
const lookup: LookupFunction = (hostname, options, callback) => {
|
||||
dns.lookup(hostname, {...options, all: true, verbatim: true}, (error, addresses) => {
|
||||
dns.lookup(hostname, {...options, all: true, order: options.order ?? 'verbatim'}, (error, addresses) => {
|
||||
if (error) {
|
||||
callback(error, []);
|
||||
return;
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
resolve: {tsconfigPaths: true},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
resolve: {tsconfigPaths: true},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
resolve: {tsconfigPaths: true},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:"
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
resolve: {tsconfigPaths: true},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
resolve: {tsconfigPaths: true},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
resolve: {tsconfigPaths: true},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {isValidationErrorCode, ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {
|
||||
InputValidationError,
|
||||
type LocalizedValidationError,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationError';
|
||||
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
|
||||
import {getCookie} from 'hono/cookie';
|
||||
import type {ZodError, ZodTypeAny} from 'zod';
|
||||
import type {core, input, output, ZodSafeParseResult, ZodType} from 'zod';
|
||||
import {requireRequestJsonBody} from './utils/RequestJsonBody';
|
||||
import {initializeFluxerErrorMap} from './ZodErrorMap';
|
||||
|
||||
@@ -19,12 +19,6 @@ function isEmptyObject(obj: object): boolean {
|
||||
return Object.keys(obj).length === 0;
|
||||
}
|
||||
|
||||
const validationErrorCodeSet = new Set<string>(Object.values(ValidationErrorCodes));
|
||||
|
||||
function isValidationErrorCode(value: string): value is ValidationErrorCode {
|
||||
return validationErrorCodeSet.has(value);
|
||||
}
|
||||
|
||||
function getValidationErrorCode(message: string): ValidationErrorCode {
|
||||
if (isValidationErrorCode(message)) {
|
||||
return message;
|
||||
@@ -32,60 +26,19 @@ function getValidationErrorCode(message: string): ValidationErrorCode {
|
||||
return ValidationErrorCodes.INVALID_FORMAT;
|
||||
}
|
||||
|
||||
interface ZodTooSmallIssue {
|
||||
code: 'too_small';
|
||||
minimum: number | bigint;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface ZodTooBigIssue {
|
||||
code: 'too_big';
|
||||
maximum: number | bigint;
|
||||
type: string;
|
||||
}
|
||||
|
||||
function isTooSmallIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodTooSmallIssue {
|
||||
return issue.code === 'too_small' && 'minimum' in issue && 'type' in issue;
|
||||
}
|
||||
|
||||
function isTooBigIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodTooBigIssue {
|
||||
return issue.code === 'too_big' && 'maximum' in issue && 'type' in issue;
|
||||
}
|
||||
|
||||
interface ZodInvalidTypeIssue {
|
||||
code: 'invalid_type';
|
||||
expected: string;
|
||||
received: string;
|
||||
}
|
||||
|
||||
interface ZodCustomIssue {
|
||||
code: 'custom';
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isInvalidTypeIssue(
|
||||
issue: ZodError['issues'][number],
|
||||
): issue is ZodError['issues'][number] & ZodInvalidTypeIssue {
|
||||
return issue.code === 'invalid_type' && 'expected' in issue && 'received' in issue;
|
||||
}
|
||||
|
||||
function isCustomIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodCustomIssue {
|
||||
return issue.code === 'custom';
|
||||
}
|
||||
|
||||
function extractVariablesFromIssue(issue: ZodError['issues'][number]): Record<string, unknown> | undefined {
|
||||
function extractVariablesFromIssue(issue: core.$ZodIssue): Record<string, unknown> {
|
||||
const path = issue.path;
|
||||
const fieldName = path.length > 0 ? String(path[path.length - 1]) : 'field';
|
||||
if (isTooSmallIssue(issue)) {
|
||||
if (issue.code === 'too_small') {
|
||||
return {name: fieldName, min: issue.minimum, minValue: issue.minimum};
|
||||
}
|
||||
if (isTooBigIssue(issue)) {
|
||||
if (issue.code === 'too_big') {
|
||||
return {name: fieldName, max: issue.maximum, maxLength: issue.maximum, maxValue: issue.maximum};
|
||||
}
|
||||
if (isInvalidTypeIssue(issue)) {
|
||||
return {name: fieldName, expected: issue.expected, received: issue.received};
|
||||
if (issue.code === 'invalid_type') {
|
||||
return {name: fieldName, expected: issue.expected};
|
||||
}
|
||||
if (isCustomIssue(issue) && issue.params) {
|
||||
if (issue.code === 'custom' && issue.params) {
|
||||
return {name: fieldName, ...issue.params};
|
||||
}
|
||||
return {name: fieldName};
|
||||
@@ -106,24 +59,15 @@ function convertEmptyValuesToNull(obj: unknown, isRoot = true): unknown {
|
||||
}
|
||||
|
||||
type HasUndefined<T> = undefined extends T ? true : false;
|
||||
type SafeParseResult<T extends ZodTypeAny> =
|
||||
| {
|
||||
success: true;
|
||||
data: T['_output'];
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: ZodError<T['_input']>;
|
||||
};
|
||||
type Hook<
|
||||
T extends ZodTypeAny,
|
||||
T extends ZodType,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
Target extends keyof ValidationTargets = keyof ValidationTargets,
|
||||
V extends Input = Input,
|
||||
O = Record<string, unknown>,
|
||||
> = (
|
||||
result: SafeParseResult<T> & {
|
||||
result: ZodSafeParseResult<output<T>> & {
|
||||
target: Target;
|
||||
},
|
||||
c: Context<E, P, V>,
|
||||
@@ -134,7 +78,7 @@ type PreHook<E extends Env, P extends string, Target extends keyof ValidationTar
|
||||
target: Target,
|
||||
) => unknown | Promise<unknown>;
|
||||
type ValidatorOptions<
|
||||
T extends ZodTypeAny,
|
||||
T extends ZodType,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
Target extends keyof ValidationTargets,
|
||||
@@ -144,7 +88,7 @@ type ValidatorOptions<
|
||||
post?: Hook<T, E, P, Target, V>;
|
||||
};
|
||||
|
||||
export function inputValidationErrorFromZodIssues(issues: ZodError['issues']): InputValidationError {
|
||||
export function inputValidationErrorFromZodIssues(issues: Array<core.$ZodIssue>): InputValidationError {
|
||||
const errors: Array<ValidationError> = [];
|
||||
const localizedErrors: Array<LocalizedValidationError> = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -162,12 +106,12 @@ export function inputValidationErrorFromZodIssues(issues: ZodError['issues']): I
|
||||
}
|
||||
|
||||
export const Validator = <
|
||||
T extends ZodTypeAny,
|
||||
T extends ZodType,
|
||||
Target extends keyof ValidationTargets,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
In = T['_input'],
|
||||
Out = T['_output'],
|
||||
In = input<T>,
|
||||
Out = output<T>,
|
||||
I extends Input = {
|
||||
in: HasUndefined<In> extends true
|
||||
? {
|
||||
|
||||
@@ -1,37 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {isValidationErrorCode, ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {z} from 'zod';
|
||||
|
||||
const validationErrorCodeSet = new Set<string>(Object.values(ValidationErrorCodes));
|
||||
|
||||
type FluxerZodErrorMapIssue = z.core.$ZodRawIssue;
|
||||
type FluxerZodErrorMapResult =
|
||||
| {
|
||||
message: string;
|
||||
}
|
||||
| string
|
||||
| undefined
|
||||
| null;
|
||||
|
||||
function isValidationErrorCode(value: string): value is ValidationErrorCode {
|
||||
return validationErrorCodeSet.has(value);
|
||||
}
|
||||
|
||||
function getParamsProperty(obj: object): Record<string, unknown> | undefined {
|
||||
if ('params' in obj) {
|
||||
const value = (
|
||||
obj as {
|
||||
params?: unknown;
|
||||
}
|
||||
).params;
|
||||
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResult {
|
||||
const fluxerZodErrorMap: z.core.$ZodErrorMap = (issue) => {
|
||||
if (issue.message && isValidationErrorCode(issue.message)) {
|
||||
return {message: issue.message};
|
||||
}
|
||||
@@ -58,8 +31,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
|
||||
break;
|
||||
}
|
||||
case 'too_big': {
|
||||
const origin = 'origin' in issue ? String(issue.origin) : undefined;
|
||||
if (origin === 'string') {
|
||||
if (issue.origin === 'string') {
|
||||
errorCode = ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH;
|
||||
} else {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
@@ -67,10 +39,9 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
|
||||
break;
|
||||
}
|
||||
case 'invalid_format': {
|
||||
const format = 'format' in issue ? String(issue.format) : undefined;
|
||||
if (format === 'email') {
|
||||
if (issue.format === 'email') {
|
||||
errorCode = ValidationErrorCodes.INVALID_EMAIL_ADDRESS;
|
||||
} else if (format === 'uuid') {
|
||||
} else if (issue.format === 'uuid') {
|
||||
errorCode = ValidationErrorCodes.INVALID_SNOWFLAKE;
|
||||
} else {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
@@ -82,8 +53,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
|
||||
break;
|
||||
}
|
||||
case 'custom': {
|
||||
const params = getParamsProperty(issue);
|
||||
const customErrorCode = params?.['error_code'];
|
||||
const customErrorCode = issue.params?.['error_code'];
|
||||
errorCode =
|
||||
typeof customErrorCode === 'string' && isValidationErrorCode(customErrorCode)
|
||||
? customErrorCode
|
||||
@@ -101,7 +71,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
|
||||
}
|
||||
}
|
||||
return {message: errorCode};
|
||||
}
|
||||
};
|
||||
|
||||
export function initializeFluxerErrorMap(): void {
|
||||
z.config({customError: fluxerZodErrorMap});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
AdminApiKeyListResponse,
|
||||
CreateAdminApiKeyRequest,
|
||||
CreateAdminApiKeyResponse,
|
||||
type CreateAdminApiKeyResponse as CreateAdminApiKeyResponseType,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
UpdateAdminApiKeyRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {KeyIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {requireAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
@@ -72,7 +73,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_api_keys',
|
||||
summary: 'List admin API keys',
|
||||
responseSchema: z.array(ListAdminApiKeyResponse),
|
||||
responseSchema: AdminApiKeyListResponse,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
|
||||
import {
|
||||
AdminArchiveCreateRequest,
|
||||
AdminArchiveResponseSchema,
|
||||
type ArchiveSubjectType,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
|
||||
import {
|
||||
AdminArchiveCreateRequest,
|
||||
DownloadUrlResponseSchema,
|
||||
GetArchiveResponseSchema,
|
||||
ListArchivesQuery,
|
||||
@@ -19,7 +22,7 @@ import {RateLimitConfigs} from '../../RateLimitConfig';
|
||||
import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
function canViewArchive(adminAcls: Set<string>, subjectType: 'user' | 'guild'): boolean {
|
||||
function canViewArchive(adminAcls: Set<string>, subjectType: ArchiveSubjectType): boolean {
|
||||
if (adminAcls.has(AdminACLs.WILDCARD) || adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL)) return true;
|
||||
if (subjectType === 'user') return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_USER);
|
||||
return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_GUILD);
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
SuspiciousEmailDomainRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import type {ZodTypeAny, z} from 'zod';
|
||||
import type {ZodType} from 'zod';
|
||||
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
@@ -221,7 +221,7 @@ function requireProfileSubstringScope(scope: ProfileSubstringScope | undefined):
|
||||
return scope;
|
||||
}
|
||||
|
||||
async function parseBlocklistBody<T extends ZodTypeAny>(schema: T, value: unknown): Promise<z.infer<T>> {
|
||||
async function parseBlocklistBody<T>(schema: ZodType<T>, value: unknown): Promise<T> {
|
||||
const result = await schema.safeParseAsync(value);
|
||||
if (!result.success) {
|
||||
throw inputValidationErrorFromZodIssues(result.error.issues);
|
||||
|
||||
@@ -6,17 +6,17 @@ import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas
|
||||
import {
|
||||
DiscoveryAdminApplicationUpdateRequest,
|
||||
DiscoveryAdminCategoryListingQuery,
|
||||
DiscoveryAdminListedGuildResponse,
|
||||
DiscoveryAdminListedGuildListResponse,
|
||||
DiscoveryAdminListingBulkCategoryRequest,
|
||||
DiscoveryAdminListingBulkCategoryResponse,
|
||||
DiscoveryAdminPendingApplicationResponse,
|
||||
DiscoveryAdminPendingApplicationListResponse,
|
||||
DiscoveryAdminRemoveRequest,
|
||||
DiscoveryApplicationPatchRequest,
|
||||
DiscoveryApplicationResponse,
|
||||
DiscoveryCategoryIdParam,
|
||||
DiscoveryCategoryListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {createGuildID} from '../../BrandedTypes';
|
||||
import type {GuildDiscoveryRow} from '../../database/types/GuildDiscoveryTypes';
|
||||
import {mapGuildFeatures} from '../../guild/GuildFeatureUtils';
|
||||
@@ -138,7 +138,7 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
summary: 'List discovery applications',
|
||||
description:
|
||||
'Returns every pending discovery application, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: z.array(DiscoveryAdminPendingApplicationResponse),
|
||||
responseSchema: DiscoveryAdminPendingApplicationListResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
@@ -214,7 +214,7 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
summary: 'List guilds in a discovery category',
|
||||
description:
|
||||
'Returns an offset page of the guilds listed under one discovery category, most members first, enriched with guild metadata. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: z.array(DiscoveryAdminListedGuildResponse),
|
||||
responseSchema: DiscoveryAdminListedGuildListResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
@@ -249,7 +249,7 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
summary: 'List discovery listings',
|
||||
description:
|
||||
'Returns every approved/listed discovery guild, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: z.array(DiscoveryAdminListedGuildResponse),
|
||||
responseSchema: DiscoveryAdminListedGuildListResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
|
||||
@@ -17,9 +17,8 @@ import {
|
||||
ListGuildStickersResponse,
|
||||
LookupGuildResponse,
|
||||
SearchGuildsResponse,
|
||||
SuccessResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {GuildIdParam, GuildIdUserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {GuildIdParam, GuildIdUserIdParam, SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {GuildAuditLogListQuery} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
|
||||
import {createGuildID} from '../../BrandedTypes';
|
||||
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
|
||||
|
||||
@@ -17,13 +17,13 @@ import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/Gatew
|
||||
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
||||
import type {Context} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {
|
||||
type InstanceBrandingConfig,
|
||||
type InstancePolicyConfig,
|
||||
REGISTRATION_PENDING_APPROVAL_TRAIT,
|
||||
REGISTRATION_REJECTED_TRAIT,
|
||||
@@ -229,14 +229,17 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
await getGatewayRolloutConfigPublisher().publish(validated);
|
||||
}
|
||||
if (data.voice_noise_suppression) {
|
||||
const patch = omitUndefinedFields(data.voice_noise_suppression);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentNoiseSuppression = await instanceConfigRepository.getVoiceNoiseSuppressionConfig();
|
||||
const validated = VoiceNoiseSuppressionConfigSchema.parse({
|
||||
...currentNoiseSuppression,
|
||||
...data.voice_noise_suppression,
|
||||
...patch,
|
||||
config_version: currentNoiseSuppression.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
|
||||
const validated = ExperimentDeliveryConfigSchema.parse({
|
||||
@@ -435,7 +438,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
base64Image: image ?? null,
|
||||
errorPath: 'image',
|
||||
});
|
||||
const brandingPatch: Partial<InstanceBrandingConfig> = {[`${kind}_url`]: prepared.newCdnUrl};
|
||||
const brandingPatch: Partial<InstanceBranding> = {[`${kind}_url`]: prepared.newCdnUrl};
|
||||
await instanceConfigRepository.setAppPublicConfig({branding: brandingPatch});
|
||||
return ctx.json(await buildInstanceConfigResponse());
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ export function SystemAdminController(app: HonoApp) {
|
||||
description:
|
||||
'Writes a V8 heap snapshot of the current process and returns the snapshot file. Used for diagnosing memory leaks. Requires SYSTEM_HEAP_SNAPSHOT permission.',
|
||||
responseSchema: HeapSnapshotResponse,
|
||||
responseContentType: 'application/octet-stream',
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CreateVoiceRegionRequest,
|
||||
CreateVoiceRegionResponse,
|
||||
CreateVoiceServerRequest,
|
||||
CreateVoiceServerRequestBody,
|
||||
CreateVoiceServerResponse,
|
||||
DeleteVoiceResponse,
|
||||
GetVoiceRegionQuery,
|
||||
@@ -14,8 +15,10 @@ import {
|
||||
ListVoiceRegionsResponse,
|
||||
ListVoiceServersResponse,
|
||||
UpdateVoiceRegionRequest,
|
||||
UpdateVoiceRegionRequestBody,
|
||||
UpdateVoiceRegionResponse,
|
||||
UpdateVoiceServerRequest,
|
||||
UpdateVoiceServerRequestBody,
|
||||
UpdateVoiceServerResponse,
|
||||
VoiceRegionIdParam,
|
||||
VoiceServerIdParam,
|
||||
@@ -117,6 +120,7 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'update_admin_voice_region',
|
||||
summary: 'Update voice region',
|
||||
requestSchema: UpdateVoiceRegionRequestBody,
|
||||
responseSchema: UpdateVoiceRegionResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -195,6 +199,7 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'create_admin_voice_server',
|
||||
summary: 'Create voice server',
|
||||
requestSchema: CreateVoiceServerRequestBody,
|
||||
responseSchema: CreateVoiceServerResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -247,6 +252,7 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'update_admin_voice_server',
|
||||
summary: 'Update voice server',
|
||||
requestSchema: UpdateVoiceServerRequestBody,
|
||||
responseSchema: UpdateVoiceServerResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {z} from 'zod';
|
||||
import type {AdminArchiveResponse, ArchiveSubjectType} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
|
||||
import type {AdminArchiveRow} from '../../database/types/AdminArchiveTypes';
|
||||
|
||||
export type ArchiveSubjectType = 'user' | 'guild';
|
||||
|
||||
export class AdminArchive {
|
||||
subjectType: ArchiveSubjectType;
|
||||
subjectId: bigint;
|
||||
@@ -61,22 +58,7 @@ export class AdminArchive {
|
||||
};
|
||||
}
|
||||
|
||||
toResponse(): {
|
||||
archive_id: string;
|
||||
subject_type: ArchiveSubjectType;
|
||||
subject_id: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
failed_at: string | null;
|
||||
file_size: string | null;
|
||||
progress_percent: number;
|
||||
progress_step: string | null;
|
||||
error_message: string | null;
|
||||
download_url_expires_at: string | null;
|
||||
expires_at: string | null;
|
||||
} {
|
||||
toResponse(): AdminArchiveResponse {
|
||||
return {
|
||||
archive_id: this.archiveId.toString(),
|
||||
subject_type: this.subjectType,
|
||||
@@ -95,5 +77,3 @@ export class AdminArchive {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AdminArchiveResponse = z.infer<typeof AdminArchiveResponseSchema>;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ArchiveSubjectType} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
import {BatchBuilder, fetchMany, fetchOne} from '../../database/CassandraQueryExecution';
|
||||
import {Db} from '../../database/CassandraTypes';
|
||||
import type {AdminArchiveRow} from '../../database/types/AdminArchiveTypes';
|
||||
import {Logger} from '../../Logger';
|
||||
import {AdminArchivesByRequester, AdminArchivesBySubject, AdminArchivesByType} from '../../Tables';
|
||||
import type {ArchiveSubjectType} from '../models/AdminArchiveModel';
|
||||
import {AdminArchive} from '../models/AdminArchiveModel';
|
||||
|
||||
const RETENTION_DAYS = 365;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestF
|
||||
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
|
||||
import {UnknownHarvestError} from '@fluxer/errors/src/domains/moderation/UnknownHarvestError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {AdminArchiveResponse, ArchiveSubjectType} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import type {GuildID, UserID} from '../../BrandedTypes';
|
||||
@@ -16,7 +17,7 @@ import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
|
||||
import {AdminArchive, type AdminArchiveResponse, type ArchiveSubjectType} from '../models/AdminArchiveModel';
|
||||
import {AdminArchive} from '../models/AdminArchiveModel';
|
||||
import type {AdminArchiveRepository} from '../repositories/AdminArchiveRepository';
|
||||
|
||||
const ARCHIVE_RETENTION_DAYS = 365;
|
||||
@@ -130,19 +131,14 @@ export class AdminArchiveService {
|
||||
|
||||
async listArchives(params: ListArchivesParams): Promise<Array<AdminArchiveResponse>> {
|
||||
const {subjectType = 'all', subjectId, requestedBy, limit = 50, includeExpired = false} = params;
|
||||
if (subjectId !== undefined && subjectType === 'all') {
|
||||
if (subjectId !== undefined) {
|
||||
if (subjectType === 'all') {
|
||||
throw InputValidationError.create(
|
||||
'subject_type',
|
||||
'subject_type must name user or guild when subject_id is supplied',
|
||||
);
|
||||
}
|
||||
if (subjectId !== undefined) {
|
||||
const archives = await this.adminArchiveRepository.listBySubject(
|
||||
subjectType as ArchiveSubjectType,
|
||||
subjectId,
|
||||
limit,
|
||||
includeExpired,
|
||||
);
|
||||
const archives = await this.adminArchiveRepository.listBySubject(subjectType, subjectId, limit, includeExpired);
|
||||
return archives.map((a) => a.toResponse());
|
||||
}
|
||||
if (requestedBy !== undefined) {
|
||||
@@ -159,11 +155,7 @@ export class AdminArchiveService {
|
||||
.slice(0, limit)
|
||||
.map((a) => a.toResponse());
|
||||
}
|
||||
const archives = await this.adminArchiveRepository.listByType(
|
||||
subjectType as ArchiveSubjectType,
|
||||
limit,
|
||||
includeExpired,
|
||||
);
|
||||
const archives = await this.adminArchiveRepository.listByType(subjectType, limit, includeExpired);
|
||||
return archives.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
AdminAuditLogChannelSummary,
|
||||
AdminAuditLogGuildSummary,
|
||||
AdminAuditLogResponse,
|
||||
AdminAuditLogUserSummary,
|
||||
AuditLogsListResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {UserID} from '../../BrandedTypes';
|
||||
import {createChannelID, createGuildID, createUserID} from '../../BrandedTypes';
|
||||
import type {IChannelRepository} from '../../channel/IChannelRepository';
|
||||
@@ -70,10 +77,7 @@ export class AdminAuditService {
|
||||
target_id?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{
|
||||
logs: Array<AdminAuditLogResponse>;
|
||||
total: number;
|
||||
}> {
|
||||
}): Promise<AuditLogsListResponse> {
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
|
||||
if (!auditLogSearchService || !auditLogSearchService.isAvailable()) {
|
||||
@@ -116,10 +120,7 @@ export class AdminAuditService {
|
||||
sort_order?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{
|
||||
logs: Array<AdminAuditLogResponse>;
|
||||
total: number;
|
||||
}> {
|
||||
}): Promise<AuditLogsListResponse> {
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
|
||||
if (!auditLogSearchService || !auditLogSearchService.isAvailable()) {
|
||||
@@ -164,10 +165,7 @@ export class AdminAuditService {
|
||||
targetId?: bigint;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{
|
||||
logs: Array<AdminAuditLogResponse>;
|
||||
total: number;
|
||||
}> {
|
||||
}): Promise<AuditLogsListResponse> {
|
||||
const limit = data.limit || 50;
|
||||
const allLogs = await this.adminRepository.listAllAuditLogsPaginated(limit + (data.offset || 0));
|
||||
let filteredLogs = allLogs;
|
||||
@@ -320,24 +318,6 @@ export class AdminAuditService {
|
||||
}
|
||||
}
|
||||
|
||||
interface AdminAuditLogResponse {
|
||||
log_id: string;
|
||||
admin_user_id: string;
|
||||
admin_user: AuditLogUserSummary | null;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
target_user: AuditLogUserSummary | null;
|
||||
target_guild: AuditLogGuildSummary | null;
|
||||
target_channel: AuditLogChannelSummary | null;
|
||||
related_users: Record<string, AuditLogUserSummary>;
|
||||
related_guilds: Record<string, AuditLogGuildSummary>;
|
||||
related_channels: Record<string, AuditLogChannelSummary>;
|
||||
action: string;
|
||||
audit_log_reason: string | null;
|
||||
metadata: Record<string, string>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AuditLogEnrichmentDeps {
|
||||
userRepository?: Pick<IUserRepository, 'findUnique'>;
|
||||
guildRepository?: Pick<IGuildRepositoryAggregate, 'findUnique'>;
|
||||
@@ -350,25 +330,6 @@ interface AuditLogEnrichment {
|
||||
channels: Map<string, Channel>;
|
||||
}
|
||||
|
||||
interface AuditLogUserSummary {
|
||||
id: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
global_name: string | null;
|
||||
}
|
||||
|
||||
interface AuditLogGuildSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AuditLogChannelSummary {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type: number;
|
||||
guild_id: string | null;
|
||||
}
|
||||
|
||||
const USER_TARGET_TYPES = new Set(['user', 'guild_member', 'message_deletion', 'message_shred']);
|
||||
const SNOWFLAKE_RE = /^(0|[1-9][0-9]*)$/;
|
||||
|
||||
@@ -388,7 +349,7 @@ function isChannelIdKey(key: string): boolean {
|
||||
return key === 'channel_id' || key.endsWith('_channel_id');
|
||||
}
|
||||
|
||||
function mapUserSummary(user: User | null): AuditLogUserSummary | null {
|
||||
function mapUserSummary(user: User | null): AdminAuditLogUserSummary | null {
|
||||
if (!user) return null;
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
@@ -398,7 +359,7 @@ function mapUserSummary(user: User | null): AuditLogUserSummary | null {
|
||||
};
|
||||
}
|
||||
|
||||
function mapGuildSummary(guild: Guild | null): AuditLogGuildSummary | null {
|
||||
function mapGuildSummary(guild: Guild | null): AdminAuditLogGuildSummary | null {
|
||||
if (!guild) return null;
|
||||
return {
|
||||
id: guild.id.toString(),
|
||||
@@ -406,7 +367,7 @@ function mapGuildSummary(guild: Guild | null): AuditLogGuildSummary | null {
|
||||
};
|
||||
}
|
||||
|
||||
function mapChannelSummary(channel: Channel | null): AuditLogChannelSummary | null {
|
||||
function mapChannelSummary(channel: Channel | null): AdminAuditLogChannelSummary | null {
|
||||
if (!channel) return null;
|
||||
return {
|
||||
id: channel.id.toString(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import type {SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {createGuildID, type GuildID, type UserID} from '../../../BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '../../../guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {GuildService} from '../../../guild/services/GuildService';
|
||||
@@ -17,7 +18,7 @@ interface AdminGuildManagementServiceDeps {
|
||||
export class AdminGuildManagementService {
|
||||
constructor(private readonly deps: AdminGuildManagementServiceDeps) {}
|
||||
|
||||
async reloadGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
async reloadGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null): Promise<SuccessResponse> {
|
||||
const {guildRepository, gatewayService, auditService} = this.deps;
|
||||
const guildId = createGuildID(guildIdRaw);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
@@ -36,7 +37,11 @@ export class AdminGuildManagementService {
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async shutdownGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
async shutdownGuild(
|
||||
guildIdRaw: bigint,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<SuccessResponse> {
|
||||
const {guildRepository, gatewayService, auditService} = this.deps;
|
||||
const guildId = createGuildID(guildIdRaw);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
@@ -55,7 +60,7 @@ export class AdminGuildManagementService {
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null): Promise<SuccessResponse> {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const guildId = createGuildID(guildIdRaw);
|
||||
await guildService.data.deleteGuildForAdmin(guildId, auditLogReason);
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ForceAddUserToGuildRequest,
|
||||
KickGuildMemberRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import type {SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {createGuildID, createUserID, type UserID} from '../../../BrandedTypes';
|
||||
import type {GuildService} from '../../../guild/services/GuildService';
|
||||
import {createRequestCache, type RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
@@ -34,7 +35,7 @@ export class AdminGuildMembershipService {
|
||||
requestCache: RequestCache;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
}): Promise<SuccessResponse> {
|
||||
const {userRepository, guildService, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
IpAuthorizationPollQuery,
|
||||
IpAuthorizationPollResponse,
|
||||
LoginRequest,
|
||||
LogoutAuthSessionsRequest,
|
||||
LogoutAuthSessionsWithVerificationRequest,
|
||||
MfaTicketRequest,
|
||||
MfaTotpRequest,
|
||||
RegisterRequest,
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
SsoStartRequest,
|
||||
SsoStartResponse,
|
||||
SsoStatusResponse,
|
||||
SudoVerificationSchema,
|
||||
UsernameSuggestionsRequest,
|
||||
UsernameSuggestionsResponse,
|
||||
ValidateResetPasswordTokenResponse,
|
||||
@@ -353,7 +352,7 @@ export function AuthController(app: HonoApp) {
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', LogoutAuthSessionsRequest.merge(SudoVerificationSchema)),
|
||||
Validator('json', LogoutAuthSessionsWithVerificationRequest),
|
||||
OpenAPI({
|
||||
operationId: 'logout_all_sessions',
|
||||
summary: 'Logout all sessions',
|
||||
|
||||
@@ -256,8 +256,8 @@ function buildRegistrationAuthData(device: WebAuthnDevice): Buffer {
|
||||
let x: Buffer, y: Buffer;
|
||||
const asn1Offset = pubKeyBuf.indexOf(Buffer.from([0x30, 0x59, 0x30, 0x13]));
|
||||
if (asn1Offset > 0 && pubKeyBuf.length >= asn1Offset + 68) {
|
||||
x = pubKeyBuf.slice(asn1Offset + 4 + 3, asn1Offset + 4 + 35);
|
||||
y = pubKeyBuf.slice(asn1Offset + 4 + 36, asn1Offset + 4 + 68);
|
||||
x = pubKeyBuf.subarray(asn1Offset + 4 + 3, asn1Offset + 4 + 35);
|
||||
y = pubKeyBuf.subarray(asn1Offset + 4 + 36, asn1Offset + 4 + 68);
|
||||
} else {
|
||||
x = randomBytes(32);
|
||||
y = randomBytes(32);
|
||||
|
||||
@@ -4,13 +4,14 @@ import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownCha
|
||||
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {
|
||||
ChannelUpdateRequest,
|
||||
ChannelUpdateRequestBody,
|
||||
DeleteChannelQuery,
|
||||
PermissionOverwriteCreateRequest,
|
||||
} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import {
|
||||
ChannelResponse,
|
||||
ChannelSlowmodeStateResponse,
|
||||
RtcRegionResponse,
|
||||
RtcRegionListResponse,
|
||||
} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {
|
||||
ChannelIdOverwriteIdParam,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
ChannelIdUserIdParam,
|
||||
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import type {Context} from 'hono';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
|
||||
import {createChannelID, createUserID} from '../../BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
|
||||
@@ -98,7 +99,7 @@ export function ChannelController(app: HonoApp) {
|
||||
summary: 'List RTC regions',
|
||||
description:
|
||||
'Returns available voice and video calling regions for the channel, used to optimise connection quality. Requires membership with call permissions.',
|
||||
responseSchema: z.array(RtcRegionResponse),
|
||||
responseSchema: RtcRegionListResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
@@ -141,6 +142,7 @@ export function ChannelController(app: HonoApp) {
|
||||
}),
|
||||
OpenAPI({
|
||||
operationId: 'update_channel',
|
||||
requestSchema: ChannelUpdateRequestBody,
|
||||
summary: 'Update channel settings',
|
||||
description:
|
||||
'Modifies channel properties such as name, description, topic, nsfw flag, and slowmode. Requires management permissions in the channel.',
|
||||
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import {
|
||||
BulkMessageFetchResponse,
|
||||
MessageListResponse,
|
||||
MessagePurgeResponse,
|
||||
MessageResponseSchema,
|
||||
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
|
||||
import {createAttachmentID, createChannelID, createMessageID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
@@ -53,7 +55,7 @@ export function MessageController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'list_messages',
|
||||
summary: 'List messages in a channel',
|
||||
responseSchema: z.array(MessageResponseSchema),
|
||||
responseSchema: MessageListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
@@ -431,7 +433,7 @@ export function MessageController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'purge_personal_notes_messages',
|
||||
summary: 'Purge all messages in personal notes',
|
||||
responseSchema: z.object({deleted_count: z.number().int().nonnegative()}),
|
||||
responseSchema: MessagePurgeResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {
|
||||
StreamPreviewResponse,
|
||||
StreamPreviewUploadBodySchema,
|
||||
StreamPreviewUploadUrlBodySchema,
|
||||
StreamPreviewUploadUrlResponseSchema,
|
||||
@@ -54,7 +55,8 @@ export function StreamController(app: HonoApp) {
|
||||
summary: 'Get stream preview image',
|
||||
description:
|
||||
'Retrieves the current preview thumbnail for a stream. Returns the image with no-store cache headers to ensure freshness.',
|
||||
responseSchema: null,
|
||||
responseSchema: StreamPreviewResponse,
|
||||
responseContentType: 'image/*',
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
|
||||
@@ -40,6 +40,7 @@ import type {Attachment} from '../../models/Attachment';
|
||||
import type {Channel} from '../../models/Channel';
|
||||
import type {Message} from '../../models/Message';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
|
||||
import {assertGuildMemberCanCommunicate} from '../../utils/GuildCommunicationUtils';
|
||||
import type {UploadedAttachment} from '../AttachmentDTOs';
|
||||
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
|
||||
@@ -488,24 +489,6 @@ export class AttachmentUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, TResult>(
|
||||
items: ReadonlyArray<T>,
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<TResult>,
|
||||
): Promise<Array<TResult>> {
|
||||
const results = new Array<TResult>(items.length);
|
||||
let nextIndex = 0;
|
||||
async function worker(): Promise<void> {
|
||||
for (;;) {
|
||||
const index = nextIndex++;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await mapper(items[index]!, index);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
async function runAttachmentStorageOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
|
||||
@@ -23,6 +23,7 @@ import {hashFileSha256} from '../../../infrastructure/StorageObjectHelpers';
|
||||
import {Logger} from '../../../Logger';
|
||||
import type {Channel} from '../../../models/Channel';
|
||||
import type {Message} from '../../../models/Message';
|
||||
import {mapWithConcurrency} from '../../../utils/ConcurrencyUtils';
|
||||
import type {AttachmentToProcess} from '../../AttachmentDTOs';
|
||||
import type {AttachmentUploadTraceRepository} from '../../repositories/message/AttachmentUploadTraceRepository';
|
||||
import {getContentType, isMediaFile, makeAttachmentCdnKey, validateAttachmentIds} from './MessageHelpers';
|
||||
@@ -30,26 +31,6 @@ import {getContentType, isMediaFile, makeAttachmentCdnKey, validateAttachmentIds
|
||||
const ATTACHMENT_PROCESSING_CONCURRENCY = 2;
|
||||
const METADATA_PROBE_DEGRADED_CONTEXT = 'message_attachment';
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: ReadonlyArray<T>,
|
||||
limit: number,
|
||||
fn: (item: T, index: number) => Promise<R>,
|
||||
): Promise<Array<R>> {
|
||||
if (items.length === 0) return [];
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (true) {
|
||||
const index = nextIndex++;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await fn(items[index], index);
|
||||
}
|
||||
};
|
||||
const workerCount = Math.min(limit, items.length);
|
||||
await Promise.all(Array.from({length: workerCount}, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
interface ProcessAttachmentParams {
|
||||
message: Message;
|
||||
attachment: AttachmentToProcess;
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function parseMultipartMessageData(
|
||||
ctx: Context<HonoEnv>,
|
||||
user: User,
|
||||
channelId: ChannelID,
|
||||
schema: z.ZodTypeAny,
|
||||
schema: z.ZodType<MessageRequest | MessageUpdateRequest>,
|
||||
options?: ParseMultipartMessageDataOptions,
|
||||
): Promise<MessageRequest | MessageUpdateRequest> {
|
||||
let body: MultipartBody;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type {ChannelID, MessageID, UserID} from '../../../BrandedTypes';
|
||||
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
import type {User} from '../../../models/User';
|
||||
import {mapWithConcurrency} from '../../../utils/ConcurrencyUtils';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '../../MessageTypes';
|
||||
import type {ChannelService} from '../ChannelService';
|
||||
import {isPersonalNotesChannel} from './MessageHelpers';
|
||||
@@ -153,21 +154,3 @@ export class MessageRequestService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, TResult>(
|
||||
items: ReadonlyArray<T>,
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<TResult>,
|
||||
): Promise<Array<TResult>> {
|
||||
const results = new Array<TResult>(items.length);
|
||||
let nextIndex = 0;
|
||||
async function worker(): Promise<void> {
|
||||
for (;;) {
|
||||
const index = nextIndex++;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await mapper(items[index], index);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {
|
||||
BulkDeleteSelfMessagesGuildFilterMode,
|
||||
BulkDeleteSelfMessagesScope,
|
||||
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import type {UserID} from '../../../BrandedTypes';
|
||||
import type {Channel} from '../../../models/Channel';
|
||||
|
||||
export type SelfMessageScopeMode = 'selected' | 'inaccessible_only';
|
||||
export type SelfMessageGuildFilterMode = 'exclude' | 'include_only';
|
||||
|
||||
export interface SelfMessageFilter {
|
||||
scope: SelfMessageScopeMode;
|
||||
scope: BulkDeleteSelfMessagesScope;
|
||||
includeDms: boolean;
|
||||
includeDmsClosed: boolean;
|
||||
includeGroupDms: boolean;
|
||||
includeGuilds: boolean;
|
||||
guildFilterMode: SelfMessageGuildFilterMode;
|
||||
guildFilterMode: BulkDeleteSelfMessagesGuildFilterMode;
|
||||
excludedGuildIds: ReadonlySet<string>;
|
||||
includedGuildIds: ReadonlySet<string>;
|
||||
startTimestamp: number | null;
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {IStorageService} from '../../../infrastructure/IStorageService';
|
||||
import {Logger} from '../../../Logger';
|
||||
import type {Message} from '../../../models/Message';
|
||||
import {deleteMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
|
||||
import {chunkArray} from '../../../utils/ArrayUtils';
|
||||
import {ChannelEventDispatcher} from '../../../worker/services/ChannelEventDispatcher';
|
||||
import {purgeMessageAttachments} from './MessageHelpers';
|
||||
import {
|
||||
@@ -50,14 +51,6 @@ interface MessageWithChannel {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
function chunkArray<T>(items: Array<T>, chunkSize: number): Array<Array<T>> {
|
||||
const chunks: Array<Array<T>> = [];
|
||||
for (let i = 0; i < items.length; i += chunkSize) {
|
||||
chunks.push(items.slice(i, i + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export class UserMessageDeletionService {
|
||||
private readonly eventDispatcher: ChannelEventDispatcher;
|
||||
private readonly FETCH_BATCH_SIZE = 100;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import {NcmecAlreadySubmittedError} from '@fluxer/errors/src/domains/moderation/NcmecAlreadySubmittedError';
|
||||
import {NcmecSubmissionFailedError} from '@fluxer/errors/src/domains/moderation/NcmecSubmissionFailedError';
|
||||
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
|
||||
import type {NcmecSubmissionStatus} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import {ms} from 'itty-time';
|
||||
@@ -58,8 +59,6 @@ import type {NcmecApiClient} from './NcmecReporter';
|
||||
import {buildNcmecFileDetailsXml, buildNcmecReportXml} from './NcmecReporter';
|
||||
import type {NcmecRepository} from './NcmecRepository';
|
||||
|
||||
export type NcmecSubmissionStatus = 'not_submitted' | 'submitting' | 'submitted' | 'failed';
|
||||
|
||||
export interface NcmecAttachmentStatusResponse {
|
||||
status: NcmecSubmissionStatus;
|
||||
ncmec_report_id: string | null;
|
||||
|
||||
@@ -4,16 +4,11 @@ import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUn
|
||||
import {getClient} from '@pkgs/cassandra/src/Client';
|
||||
import cassandra from 'cassandra-driver';
|
||||
import {Logger} from '../Logger';
|
||||
import {chunkArray} from '../utils/ArrayUtils';
|
||||
import {logBatch, logQuery} from './CassandraDevLogger';
|
||||
import {getIsDev} from './CassandraMetaRegistry';
|
||||
import type {CassandraParams, KvQueryMeta, PreparedQuery, QueryTemplate} from './CassandraTypes';
|
||||
import {
|
||||
assertNoUndefinedParams,
|
||||
chunkArray,
|
||||
getStatementMeta,
|
||||
normalizeExecuteArgs,
|
||||
normalizeInParams,
|
||||
} from './CassandraTypes';
|
||||
import {assertNoUndefinedParams, getStatementMeta, normalizeExecuteArgs, normalizeInParams} from './CassandraTypes';
|
||||
|
||||
const DEFAULT_MAX_PARTITION_KEYS_PER_QUERY = 100;
|
||||
|
||||
|
||||
@@ -366,14 +366,3 @@ export function assertNoUndefinedParams(params: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function chunkArray<T>(items: Array<T>, size: number): Array<Array<T>> {
|
||||
const chunks: Array<Array<T>> = [];
|
||||
if (size <= 0) {
|
||||
throw new Error('Chunk size must be greater than 0');
|
||||
}
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
chunks.push(items.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ArchiveSubjectType} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
|
||||
|
||||
export interface AdminArchiveRow {
|
||||
subject_type: 'user' | 'guild';
|
||||
subject_type: ArchiveSubjectType;
|
||||
subject_id: bigint;
|
||||
archive_id: bigint;
|
||||
requested_by: bigint;
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
DesktopVersionsParam,
|
||||
DesktopVersionsQuery,
|
||||
DesktopVersionsResponse,
|
||||
DownloadChecksumResponse,
|
||||
DownloadFileResponse,
|
||||
VersionInfoResponse,
|
||||
} from '@fluxer/schema/src/domains/download/DownloadSchemas';
|
||||
import type {Context, Hono} from 'hono';
|
||||
@@ -204,7 +206,8 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
|
||||
OpenAPI({
|
||||
operationId: 'download_latest_desktop_version_checksum',
|
||||
summary: 'Download latest desktop version checksum',
|
||||
responseSchema: null,
|
||||
responseSchema: DownloadChecksumResponse,
|
||||
responseContentType: 'text/plain',
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Downloads'],
|
||||
@@ -231,8 +234,10 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
|
||||
OpenAPI({
|
||||
operationId: 'download_latest_desktop_version',
|
||||
summary: 'Download latest desktop version',
|
||||
responseSchema: null,
|
||||
statusCode: 200,
|
||||
responseSchema: DownloadFileResponse,
|
||||
responseContentType: '*/*',
|
||||
statusCode: [200, 206, 302],
|
||||
bodylessStatusCodes: [302],
|
||||
security: [],
|
||||
tags: ['Downloads'],
|
||||
description:
|
||||
@@ -288,7 +293,8 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
|
||||
OpenAPI({
|
||||
operationId: 'download_desktop_version_checksum',
|
||||
summary: 'Download desktop version checksum',
|
||||
responseSchema: null,
|
||||
responseSchema: DownloadChecksumResponse,
|
||||
responseContentType: 'text/plain',
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Downloads'],
|
||||
@@ -315,8 +321,10 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
|
||||
OpenAPI({
|
||||
operationId: 'download_desktop_version',
|
||||
summary: 'Download desktop version',
|
||||
responseSchema: null,
|
||||
statusCode: 200,
|
||||
responseSchema: DownloadFileResponse,
|
||||
responseContentType: '*/*',
|
||||
statusCode: [200, 206, 302],
|
||||
bodylessStatusCodes: [302],
|
||||
security: [],
|
||||
tags: ['Downloads'],
|
||||
description:
|
||||
@@ -340,8 +348,10 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
|
||||
OpenAPI({
|
||||
operationId: 'download_file',
|
||||
summary: 'Download file',
|
||||
responseSchema: null,
|
||||
statusCode: 200,
|
||||
responseSchema: DownloadFileResponse,
|
||||
responseContentType: '*/*',
|
||||
statusCode: [200, 206, 302],
|
||||
bodylessStatusCodes: [302],
|
||||
security: [],
|
||||
tags: ['Downloads'],
|
||||
description:
|
||||
|
||||
@@ -22,7 +22,7 @@ export function ExperimentController(app: HonoApp) {
|
||||
description:
|
||||
'Returns the polling cadence and every experiment assignment resolved for the authenticated user from the instance configuration. Clients revalidate with If-None-Match and receive 304 when nothing changed.',
|
||||
responseSchema: ExperimentAssignmentsResponse,
|
||||
statusCode: 200,
|
||||
statusCode: [200, 304],
|
||||
security: ['bearerToken', 'sessionToken', 'botToken'],
|
||||
tags: ['Experiments'],
|
||||
}),
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
import {
|
||||
GifFeaturedResponse,
|
||||
GifListResponse,
|
||||
GifLocaleQuery,
|
||||
GifRegisterShareRequest,
|
||||
GifResponse,
|
||||
GifSearchQuery,
|
||||
GifSearchSuggestionsResponse,
|
||||
} from '@fluxer/schema/src/domains/gif/GifSchemas';
|
||||
import type {Context, MiddlewareHandler} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {DefaultUserOnly, LoginRequired} from '../middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
@@ -61,7 +62,7 @@ function registerRoutes(app: HonoApp, cfg: PrefixConfig) {
|
||||
OpenAPI({
|
||||
operationId: `search_${operationSuffix}`,
|
||||
summary: `Search GIFs${deprecated ? ' (deprecated alias)' : ''}`,
|
||||
responseSchema: z.array(GifResponse),
|
||||
responseSchema: GifListResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: [...tags],
|
||||
@@ -109,7 +110,7 @@ function registerRoutes(app: HonoApp, cfg: PrefixConfig) {
|
||||
OpenAPI({
|
||||
operationId: `get_trending_${operationSuffix}`,
|
||||
summary: `Get trending GIFs${deprecated ? ' (deprecated alias)' : ''}`,
|
||||
responseSchema: z.array(GifResponse),
|
||||
responseSchema: GifListResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: [...tags],
|
||||
@@ -162,7 +163,7 @@ function registerRoutes(app: HonoApp, cfg: PrefixConfig) {
|
||||
OpenAPI({
|
||||
operationId: `get_${operationSuffix}_search_suggestions`,
|
||||
summary: `Get GIF search suggestions${deprecated ? ' (deprecated alias)' : ''}`,
|
||||
responseSchema: z.array(z.string()),
|
||||
responseSchema: GifSearchSuggestionsResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: [...tags],
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {GuildBanResponse, GuildMemberResponse} from '@fluxer/schema/src/dom
|
||||
import type {GuildPartialResponse, GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import type {z} from 'zod';
|
||||
import {
|
||||
stripGuildBannerForFeatures,
|
||||
stripGuildIconForFeatures,
|
||||
@@ -27,7 +26,7 @@ import type {GuildSticker} from '../models/GuildSticker';
|
||||
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '../user/UserCacheHelpers';
|
||||
import {mapGuildFeatures} from './GuildFeatureUtils';
|
||||
|
||||
export function mapGuildToPartialResponse(guild: Guild): z.infer<typeof GuildPartialResponse> {
|
||||
export function mapGuildToPartialResponse(guild: Guild): GuildPartialResponse {
|
||||
const guildId = guild.id.toString();
|
||||
const iconHash = stripGuildIconForFeatures(guild.iconHash, guild.features);
|
||||
const bannerHash = stripGuildBannerForFeatures(guild.bannerHash, guild.features);
|
||||
@@ -56,7 +55,7 @@ export function mapGuildToGuildResponse(
|
||||
options?: {
|
||||
permissions?: bigint | null;
|
||||
},
|
||||
): z.infer<typeof GuildResponse> {
|
||||
): GuildResponse {
|
||||
const iconHash = stripGuildIconForFeatures(guild.iconHash, guild.features);
|
||||
const bannerHash = stripGuildBannerForFeatures(guild.bannerHash, guild.features);
|
||||
const splashHash = stripGuildSplashForFeatures(guild.splashHash, guild.features);
|
||||
@@ -97,7 +96,7 @@ export function mapGuildToGuildResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGuildRoleToResponse(role: GuildRole): z.infer<typeof GuildRoleResponse> {
|
||||
export function mapGuildRoleToResponse(role: GuildRole): GuildRoleResponse {
|
||||
return {
|
||||
id: role.id.toString(),
|
||||
name: role.name,
|
||||
@@ -110,7 +109,7 @@ export function mapGuildRoleToResponse(role: GuildRole): z.infer<typeof GuildRol
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGuildEmojiToResponse(emoji: GuildEmoji): z.infer<typeof GuildEmojiResponse> {
|
||||
export function mapGuildEmojiToResponse(emoji: GuildEmoji): GuildEmojiResponse {
|
||||
const id = emoji.id.toString();
|
||||
return {
|
||||
id,
|
||||
@@ -120,7 +119,7 @@ export function mapGuildEmojiToResponse(emoji: GuildEmoji): z.infer<typeof Guild
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGuildStickerToResponse(sticker: GuildSticker): z.infer<typeof GuildStickerResponse> {
|
||||
export function mapGuildStickerToResponse(sticker: GuildSticker): GuildStickerResponse {
|
||||
const id = sticker.id.toString();
|
||||
return {
|
||||
id,
|
||||
@@ -132,10 +131,7 @@ export function mapGuildStickerToResponse(sticker: GuildSticker): z.infer<typeof
|
||||
};
|
||||
}
|
||||
|
||||
function mapMemberWithUser(
|
||||
member: GuildMember,
|
||||
userPartial: z.infer<typeof UserPartialResponse>,
|
||||
): z.infer<typeof GuildMemberResponse> {
|
||||
function mapMemberWithUser(member: GuildMember, userPartial: UserPartialResponse): GuildMemberResponse {
|
||||
const now = Date.now();
|
||||
const isTimedOut = member.communicationDisabledUntil != null && member.communicationDisabledUntil.getTime() > now;
|
||||
return {
|
||||
@@ -154,7 +150,7 @@ function mapMemberWithUser(
|
||||
};
|
||||
}
|
||||
|
||||
export function isGuildMemberTimedOut(member?: z.infer<typeof GuildMemberResponse> | null): boolean {
|
||||
export function isGuildMemberTimedOut(member?: GuildMemberResponse | null): boolean {
|
||||
if (!member?.communication_disabled_until) {
|
||||
return false;
|
||||
}
|
||||
@@ -166,15 +162,12 @@ export async function mapGuildMemberToResponse(
|
||||
member: GuildMember,
|
||||
userCacheService: Pick<UserCacheService, 'getUserPartialResponse'>,
|
||||
requestCache: RequestCache,
|
||||
): Promise<z.infer<typeof GuildMemberResponse>> {
|
||||
): Promise<GuildMemberResponse> {
|
||||
const userPartial = await getCachedUserPartialResponse({userId: member.userId, userCacheService, requestCache});
|
||||
return mapMemberWithUser(member, userPartial);
|
||||
}
|
||||
|
||||
function mapEmojiWithUser(
|
||||
emoji: GuildEmoji,
|
||||
userPartial: z.infer<typeof UserPartialResponse>,
|
||||
): z.infer<typeof GuildEmojiWithUserResponse> {
|
||||
function mapEmojiWithUser(emoji: GuildEmoji, userPartial: UserPartialResponse): GuildEmojiWithUserResponse {
|
||||
const id = emoji.id.toString();
|
||||
return {
|
||||
id,
|
||||
@@ -189,7 +182,7 @@ export async function mapGuildEmojisWithUsersToResponse(
|
||||
emojis: Array<GuildEmoji>,
|
||||
userCacheService: Pick<UserCacheService, 'getUserPartialResponses'>,
|
||||
requestCache: RequestCache,
|
||||
): Promise<Array<z.infer<typeof GuildEmojiWithUserResponse>>> {
|
||||
): Promise<Array<GuildEmojiWithUserResponse>> {
|
||||
const userIds = [...new Set(emojis.map((emoji) => emoji.creatorId))];
|
||||
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
|
||||
return emojis
|
||||
@@ -197,10 +190,7 @@ export async function mapGuildEmojisWithUsersToResponse(
|
||||
.map((emoji) => mapEmojiWithUser(emoji, userPartials.get(emoji.creatorId)!));
|
||||
}
|
||||
|
||||
function mapStickerWithUser(
|
||||
sticker: GuildSticker,
|
||||
userPartial: z.infer<typeof UserPartialResponse>,
|
||||
): z.infer<typeof GuildStickerWithUserResponse> {
|
||||
function mapStickerWithUser(sticker: GuildSticker, userPartial: UserPartialResponse): GuildStickerWithUserResponse {
|
||||
return {
|
||||
id: sticker.id.toString(),
|
||||
name: sticker.name,
|
||||
@@ -216,7 +206,7 @@ export async function mapGuildStickersWithUsersToResponse(
|
||||
stickers: Array<GuildSticker>,
|
||||
userCacheService: Pick<UserCacheService, 'getUserPartialResponses'>,
|
||||
requestCache: RequestCache,
|
||||
): Promise<Array<z.infer<typeof GuildStickerWithUserResponse>>> {
|
||||
): Promise<Array<GuildStickerWithUserResponse>> {
|
||||
const userIds = [...new Set(stickers.map((sticker) => sticker.creatorId))];
|
||||
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
|
||||
return stickers
|
||||
@@ -224,10 +214,7 @@ export async function mapGuildStickersWithUsersToResponse(
|
||||
.map((sticker) => mapStickerWithUser(sticker, userPartials.get(sticker.creatorId)!));
|
||||
}
|
||||
|
||||
function mapBanWithUser(
|
||||
ban: GuildBan,
|
||||
userPartial: z.infer<typeof UserPartialResponse>,
|
||||
): z.infer<typeof GuildBanResponse> {
|
||||
function mapBanWithUser(ban: GuildBan, userPartial: UserPartialResponse): GuildBanResponse {
|
||||
return {
|
||||
user: userPartial,
|
||||
reason: ban.reason,
|
||||
@@ -241,7 +228,7 @@ export async function mapGuildBansToResponse(
|
||||
bans: Array<GuildBan>,
|
||||
userCacheService: Pick<UserCacheService, 'getUserPartialResponses'>,
|
||||
requestCache: RequestCache,
|
||||
): Promise<Array<z.infer<typeof GuildBanResponse>>> {
|
||||
): Promise<Array<GuildBanResponse>> {
|
||||
const userIds = [...new Set(bans.map((ban) => ban.userId))];
|
||||
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
|
||||
return bans
|
||||
|
||||
@@ -14,8 +14,12 @@ import {
|
||||
GuildVanityURLUpdateRequest,
|
||||
GuildVanityURLUpdateResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {GuildResponse, GuildVanityURLResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {z} from 'zod';
|
||||
import {
|
||||
GuildListResponse,
|
||||
GuildResponse,
|
||||
GuildVanityURLResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
|
||||
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
|
||||
import {createGuildID} from '../../BrandedTypes';
|
||||
@@ -68,7 +72,7 @@ export function GuildBaseController(app: HonoApp) {
|
||||
operationId: 'list_guilds',
|
||||
summary: 'List current user guilds',
|
||||
description: 'Requires guilds OAuth scope if using bearer token. Returns all guilds the user is a member of.',
|
||||
responseSchema: z.array(GuildResponse),
|
||||
responseSchema: GuildListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
ChannelCreateRequest,
|
||||
ChannelPositionUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {ChannelListResponse, ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {createChannelID, createGuildID} from '../../BrandedTypes';
|
||||
import {LoginRequired} from '../../middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
@@ -24,7 +24,7 @@ export function GuildChannelController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_channels',
|
||||
summary: 'List guild channels',
|
||||
responseSchema: z.array(ChannelResponse),
|
||||
responseSchema: ChannelListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {
|
||||
GuildIdParam,
|
||||
GuildIdUserIdParam,
|
||||
GuildIdUserIdRoleIdParam,
|
||||
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {GuildBanResponse, GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import {
|
||||
GuildBanListResponse,
|
||||
GuildMemberListResponse,
|
||||
GuildMemberResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import {
|
||||
GuildBanCreateRequest,
|
||||
GuildMemberListQuery,
|
||||
GuildMemberUpdateRequest,
|
||||
GuildTransferOwnershipRequest,
|
||||
GuildTransferOwnershipWithVerificationRequest,
|
||||
MyGuildMemberUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
|
||||
import {createGuildID, createRoleID, createUserID} from '../../BrandedTypes';
|
||||
import {LoginRequired} from '../../middleware/AuthMiddleware';
|
||||
@@ -36,7 +39,7 @@ export function GuildMemberController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_members',
|
||||
summary: 'List guild members',
|
||||
responseSchema: z.array(GuildMemberResponse),
|
||||
responseSchema: GuildMemberListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
@@ -194,7 +197,7 @@ export function GuildMemberController(app: HonoApp) {
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdParam),
|
||||
SudoModeMiddleware,
|
||||
Validator('json', GuildTransferOwnershipRequest.merge(SudoVerificationSchema)),
|
||||
Validator('json', GuildTransferOwnershipWithVerificationRequest),
|
||||
OpenAPI({
|
||||
operationId: 'transfer_guild_ownership',
|
||||
summary: 'Transfer guild ownership',
|
||||
@@ -227,7 +230,7 @@ export function GuildMemberController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_bans',
|
||||
summary: 'List guild bans',
|
||||
responseSchema: z.array(GuildBanResponse),
|
||||
responseSchema: GuildBanListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
GuildRolePositionsRequest,
|
||||
GuildRoleUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
|
||||
import {z} from 'zod';
|
||||
import {GuildRoleListResponse, GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
|
||||
|
||||
import {createGuildID, createRoleID} from '../../BrandedTypes';
|
||||
import {LoginRequired} from '../../middleware/AuthMiddleware';
|
||||
import {requireOAuth2ScopeForBearer} from '../../middleware/OAuth2ScopeMiddleware';
|
||||
@@ -29,7 +29,7 @@ export function GuildRoleController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_roles',
|
||||
summary: 'List guild roles',
|
||||
responseSchema: z.array(GuildRoleResponse),
|
||||
responseSchema: GuildRoleListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Guilds'],
|
||||
|
||||
@@ -11,13 +11,18 @@ import {ResourceLockedError} from '@fluxer/errors/src/domains/core/ResourceLocke
|
||||
import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError';
|
||||
import type {
|
||||
AuditLogOptions,
|
||||
AuditLogWebhookResponse,
|
||||
GuildAuditLogEntryResponse,
|
||||
GuildAuditLogListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
|
||||
import type {
|
||||
GuildEmojiMetadataResponse,
|
||||
GuildStickerMetadataResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
@@ -50,40 +55,17 @@ import {GuildModerationService} from './GuildModerationService';
|
||||
import {GuildRoleService} from './GuildRoleService';
|
||||
import {GuildSearchService} from './GuildSearchService';
|
||||
|
||||
interface AuditLogOptions {
|
||||
channel_id?: string;
|
||||
count?: number;
|
||||
delete_member_days?: string;
|
||||
id?: string;
|
||||
integration_type?: number;
|
||||
message_id?: string;
|
||||
members_removed?: number;
|
||||
role_name?: string;
|
||||
type?: number;
|
||||
inviter_id?: string;
|
||||
max_age?: number;
|
||||
max_uses?: number;
|
||||
temporary?: boolean;
|
||||
uses?: number;
|
||||
}
|
||||
|
||||
interface GuildAuditLogEntryResponse {
|
||||
id: string;
|
||||
action_type: number;
|
||||
user_id: string | null;
|
||||
target_id: string | null;
|
||||
reason?: string;
|
||||
options?: AuditLogOptions;
|
||||
interface StoredGuildAuditLogEntryResponse extends Omit<GuildAuditLogEntryResponse, 'changes'> {
|
||||
changes?: GuildAuditLogChange;
|
||||
}
|
||||
|
||||
interface AuditLogWebhook {
|
||||
id: string;
|
||||
interface StoredAuditLogWebhookResponse extends Omit<AuditLogWebhookResponse, 'type'> {
|
||||
type: number;
|
||||
guild_id: string | null;
|
||||
channel_id: string | null;
|
||||
name: string;
|
||||
avatar_hash: string | null;
|
||||
}
|
||||
|
||||
interface StoredGuildAuditLogListResponse extends Omit<GuildAuditLogListResponse, 'audit_log_entries' | 'webhooks'> {
|
||||
audit_log_entries: Array<StoredGuildAuditLogEntryResponse>;
|
||||
webhooks: Array<StoredAuditLogWebhookResponse>;
|
||||
}
|
||||
|
||||
interface GuildAuth {
|
||||
@@ -303,11 +285,7 @@ export class GuildService {
|
||||
afterLogId?: bigint;
|
||||
filterUserId?: UserID;
|
||||
actionType?: AuditLogActionType;
|
||||
}): Promise<{
|
||||
audit_log_entries: Array<GuildAuditLogEntryResponse>;
|
||||
users: Array<UserPartialResponse>;
|
||||
webhooks: Array<AuditLogWebhook>;
|
||||
}> {
|
||||
}): Promise<StoredGuildAuditLogListResponse> {
|
||||
const {userId, guildId} = params;
|
||||
const [hasPermission, guild] = await Promise.all([
|
||||
this.gatewayService.checkPermission({
|
||||
@@ -334,11 +312,7 @@ export class GuildService {
|
||||
afterLogId?: bigint;
|
||||
filterUserId?: UserID;
|
||||
actionType?: AuditLogActionType;
|
||||
}): Promise<{
|
||||
audit_log_entries: Array<GuildAuditLogEntryResponse>;
|
||||
users: Array<UserPartialResponse>;
|
||||
webhooks: Array<AuditLogWebhook>;
|
||||
}> {
|
||||
}): Promise<StoredGuildAuditLogListResponse> {
|
||||
const {guildId, requestCache, limit = 50, beforeLogId, afterLogId, filterUserId, actionType} = params;
|
||||
if (beforeLogId !== undefined && afterLogId !== undefined) {
|
||||
throw InputValidationError.fromCode('before', ValidationErrorCodes.CANNOT_SPECIFY_BOTH_BEFORE_AND_AFTER);
|
||||
@@ -412,7 +386,7 @@ export class GuildService {
|
||||
};
|
||||
}
|
||||
|
||||
private mapAuditLogToEntry(log: GuildAuditLog): GuildAuditLogEntryResponse {
|
||||
private mapAuditLogToEntry(log: GuildAuditLog): StoredGuildAuditLogEntryResponse {
|
||||
return {
|
||||
id: log.logId.toString(),
|
||||
action_type: log.actionType,
|
||||
@@ -532,7 +506,7 @@ export class GuildService {
|
||||
return {webhooks: foundWebhooks};
|
||||
}
|
||||
|
||||
private buildAuditLogWebhookResponses(webhooks: Array<Webhook>): Array<AuditLogWebhook> {
|
||||
private buildAuditLogWebhookResponses(webhooks: Array<Webhook>): Array<StoredAuditLogWebhookResponse> {
|
||||
return webhooks.map((webhook) => ({
|
||||
id: webhook.id.toString(),
|
||||
type: webhook.type,
|
||||
|
||||
@@ -188,7 +188,7 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
}
|
||||
|
||||
private async withLookupDeadline<T>(lookup: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
lookup,
|
||||
|
||||
@@ -7,15 +7,25 @@ import {
|
||||
GatewayRolloutConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
VoiceNoiseSuppressionConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
|
||||
type ExperimentDeliveryConfig,
|
||||
ExperimentDeliveryConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {
|
||||
type InstanceAppPublic,
|
||||
type InstanceBranding,
|
||||
type InstanceCaptchaProvider,
|
||||
InstanceCaptchaProviderSchema,
|
||||
type InstanceCommunity,
|
||||
type InstanceRegistration,
|
||||
type InstanceRegistrationMode,
|
||||
InstanceRegistrationModeSchema,
|
||||
type InstanceServices,
|
||||
type InstanceSetup,
|
||||
} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {Config} from '../Config';
|
||||
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '../config/APIConfig';
|
||||
@@ -56,47 +66,10 @@ const DEFAULT_GATEWAY_ROLLOUT_CONFIG: GatewayRolloutConfig = {
|
||||
voice_e2ee_scope: 'guild_feature_only',
|
||||
};
|
||||
|
||||
function cloneDefaultVoiceNoiseSuppressionConfig(): VoiceNoiseSuppressionConfig {
|
||||
return {
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled_backends: [...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG.enabled_backends],
|
||||
included_user_ids: [...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG.included_user_ids],
|
||||
excluded_user_ids: [...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG.excluded_user_ids],
|
||||
guild_overrides: [...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG.guild_overrides],
|
||||
};
|
||||
}
|
||||
export type InstanceRegistrationConfig = InstanceRegistration;
|
||||
|
||||
function cloneDefaultExperimentDeliveryConfig(): ExperimentDeliveryConfig {
|
||||
return {...DEFAULT_EXPERIMENT_DELIVERY_CONFIG};
|
||||
}
|
||||
export type InstanceRegistrationMode = 'open' | 'approval' | 'closed';
|
||||
export interface InstanceRegistrationConfig {
|
||||
mode: InstanceRegistrationMode;
|
||||
admin_registration_urls_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface InstanceBrandingConfig {
|
||||
product_name: string;
|
||||
icon_url: string | null;
|
||||
symbol_url: string | null;
|
||||
logo_url: string | null;
|
||||
wordmark_url: string | null;
|
||||
favicon_url: string | null;
|
||||
theme_color: string | null;
|
||||
}
|
||||
|
||||
interface InstanceAppPublicConfig {
|
||||
branding: InstanceBrandingConfig;
|
||||
setup: {
|
||||
configured: boolean;
|
||||
};
|
||||
legal: {
|
||||
terms_url: string | null;
|
||||
privacy_url: string | null;
|
||||
};
|
||||
registration: {
|
||||
collect_date_of_birth: boolean;
|
||||
};
|
||||
interface InstanceAppPublicConfig extends Omit<InstanceAppPublic, 'setup'> {
|
||||
setup: Pick<InstanceSetup, 'configured'>;
|
||||
}
|
||||
|
||||
export type InstancePremiumMode = 'mirror' | 'everyone';
|
||||
@@ -115,19 +88,6 @@ export interface InstancePolicyConfig {
|
||||
deferred_phone_gate_member_threshold: number;
|
||||
}
|
||||
|
||||
interface InstanceCommunityPublicConfig {
|
||||
single_community: boolean;
|
||||
single_community_guild_id: string | null;
|
||||
direct_messages_disabled: boolean;
|
||||
}
|
||||
|
||||
interface InstanceServicesPublicConfig {
|
||||
gif_enabled: boolean;
|
||||
youtube_enabled: boolean;
|
||||
bluesky_enabled: boolean;
|
||||
}
|
||||
|
||||
export type InstanceCaptchaProvider = 'hcaptcha' | 'turnstile' | 'none';
|
||||
type InstanceEmailProvider = 'smtp' | 'none';
|
||||
|
||||
interface InstanceGifIntegrationConfig {
|
||||
@@ -341,7 +301,7 @@ function isStringArray(value: unknown): value is Array<string> {
|
||||
}
|
||||
|
||||
function isRegistrationMode(value: unknown): value is InstanceRegistrationMode {
|
||||
return value === 'open' || value === 'approval' || value === 'closed';
|
||||
return InstanceRegistrationModeSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
function normalizeNullableString(value: unknown): string | null {
|
||||
@@ -542,7 +502,7 @@ const DEFAULT_INSTANCE_MEDIA_CONFIG: InstanceMediaConfig = {
|
||||
};
|
||||
|
||||
function isCaptchaProvider(value: unknown): value is InstanceCaptchaProvider {
|
||||
return value === 'hcaptcha' || value === 'turnstile' || value === 'none';
|
||||
return InstanceCaptchaProviderSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
function isEmailProvider(value: unknown): value is InstanceEmailProvider {
|
||||
@@ -1079,16 +1039,16 @@ export class InstanceConfigRepository {
|
||||
async getVoiceNoiseSuppressionConfig(): Promise<VoiceNoiseSuppressionConfig> {
|
||||
const raw = await this.getConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY);
|
||||
if (!raw) {
|
||||
return cloneDefaultVoiceNoiseSuppressionConfig();
|
||||
return VoiceNoiseSuppressionConfigSchema.parse({});
|
||||
}
|
||||
const parsed = parseJsonRecord(raw);
|
||||
if (!parsed) {
|
||||
return cloneDefaultVoiceNoiseSuppressionConfig();
|
||||
return VoiceNoiseSuppressionConfigSchema.parse({});
|
||||
}
|
||||
const result = VoiceNoiseSuppressionConfigSchema.safeParse({...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG, ...parsed});
|
||||
const result = VoiceNoiseSuppressionConfigSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
Logger.error({error: result.error}, 'Invalid voice noise suppression config');
|
||||
return cloneDefaultVoiceNoiseSuppressionConfig();
|
||||
return VoiceNoiseSuppressionConfigSchema.parse({});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -1100,16 +1060,16 @@ export class InstanceConfigRepository {
|
||||
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
|
||||
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
|
||||
if (!raw) {
|
||||
return cloneDefaultExperimentDeliveryConfig();
|
||||
return ExperimentDeliveryConfigSchema.parse({});
|
||||
}
|
||||
const parsed = parseJsonRecord(raw);
|
||||
if (!parsed) {
|
||||
return cloneDefaultExperimentDeliveryConfig();
|
||||
return ExperimentDeliveryConfigSchema.parse({});
|
||||
}
|
||||
const result = ExperimentDeliveryConfigSchema.safeParse({...DEFAULT_EXPERIMENT_DELIVERY_CONFIG, ...parsed});
|
||||
const result = ExperimentDeliveryConfigSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
Logger.error({error: result.error}, 'Invalid experiment delivery config');
|
||||
return cloneDefaultExperimentDeliveryConfig();
|
||||
return ExperimentDeliveryConfigSchema.parse({});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -1156,7 +1116,7 @@ export class InstanceConfigRepository {
|
||||
}
|
||||
|
||||
async setAppPublicConfig(config: {
|
||||
branding?: Partial<InstanceBrandingConfig>;
|
||||
branding?: Partial<InstanceBranding>;
|
||||
setup?: Partial<InstanceAppPublicConfig['setup']>;
|
||||
legal?: Partial<InstanceAppPublicConfig['legal']>;
|
||||
registration?: Partial<InstanceAppPublicConfig['registration']>;
|
||||
@@ -1468,7 +1428,7 @@ export class InstanceConfigRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async getInstanceCommunityPublicConfig(): Promise<InstanceCommunityPublicConfig> {
|
||||
async getInstanceCommunityPublicConfig(): Promise<InstanceCommunity> {
|
||||
const policy = await this.getInstancePolicyConfig();
|
||||
return {
|
||||
single_community: policy.single_community_enabled,
|
||||
@@ -1477,7 +1437,7 @@ export class InstanceConfigRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async getResolvedServicesConfig(): Promise<InstanceServicesPublicConfig> {
|
||||
async getResolvedServicesConfig(): Promise<InstanceServices> {
|
||||
const [policy, gif, youtubeApiKey, bluesky] = await Promise.all([
|
||||
this.getInstancePolicyConfig(),
|
||||
this.getEffectiveGifConfig(),
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import {ChannelIdParam, GuildIdParam, InviteCodeParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
ChannelInviteCreateRequest,
|
||||
InviteMetadataListResponse,
|
||||
InviteMetadataResponseSchema,
|
||||
InviteResponseSchema,
|
||||
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {createChannelID, createGuildID, createInviteCode} from '../BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '../middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
@@ -129,7 +130,7 @@ export function InviteController(app: HonoApp) {
|
||||
summary: 'List channel invites',
|
||||
description:
|
||||
'Retrieves all currently active invites for the specified channel, including invite codes, creators, expiration times, and usage statistics. The authenticated user must have permission to manage invites for the channel. Returns an array of invite metadata objects.',
|
||||
responseSchema: z.array(InviteMetadataResponseSchema),
|
||||
responseSchema: InviteMetadataListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Invites'],
|
||||
@@ -152,7 +153,7 @@ export function InviteController(app: HonoApp) {
|
||||
summary: 'List guild invites',
|
||||
description:
|
||||
'Retrieves all currently active invites across all channels in the specified guild, including invite codes, creators, expiration times, and usage statistics. The authenticated user must have permission to manage invites for the guild. Returns an array of invite metadata objects.',
|
||||
responseSchema: z.array(InviteMetadataResponseSchema),
|
||||
responseSchema: InviteMetadataListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Invites'],
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
GuildInviteMetadataResponse,
|
||||
GuildInviteResponse,
|
||||
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import type {z} from 'zod';
|
||||
import type {ChannelID, GuildID} from '../BrandedTypes';
|
||||
import type {IGatewayService} from '../infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '../infrastructure/UserCacheService';
|
||||
@@ -23,21 +22,8 @@ interface MapInviteToGuildInviteResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<z.infer<typeof ChannelPartialResponse>>;
|
||||
getGuildResponse: (guildId: GuildID) => Promise<z.infer<typeof GuildPartialResponse>>;
|
||||
getGuildCounts: (guildId: GuildID) => Promise<{
|
||||
memberCount: number;
|
||||
presenceCount: number;
|
||||
}>;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
interface MapInviteToGuildInviteMetadataResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<z.infer<typeof ChannelPartialResponse>>;
|
||||
getGuildResponse: (guildId: GuildID) => Promise<z.infer<typeof GuildPartialResponse>>;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<ChannelPartialResponse>;
|
||||
getGuildResponse: (guildId: GuildID) => Promise<GuildPartialResponse>;
|
||||
getGuildCounts: (guildId: GuildID) => Promise<{
|
||||
memberCount: number;
|
||||
presenceCount: number;
|
||||
@@ -49,16 +35,7 @@ interface MapInviteToGroupDmInviteResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<z.infer<typeof ChannelPartialResponse>>;
|
||||
getChannelSystem: (channelId: ChannelID) => Promise<Channel | null>;
|
||||
getChannelMemberCount: (channelId: ChannelID) => Promise<number>;
|
||||
}
|
||||
|
||||
interface MapInviteToGroupDmInviteMetadataResponseParams {
|
||||
invite: Invite;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<z.infer<typeof ChannelPartialResponse>>;
|
||||
getChannelResponse: (channelId: ChannelID) => Promise<ChannelPartialResponse>;
|
||||
getChannelSystem: (channelId: ChannelID) => Promise<Channel | null>;
|
||||
getChannelMemberCount: (channelId: ChannelID) => Promise<number>;
|
||||
}
|
||||
@@ -71,7 +48,7 @@ export async function mapInviteToGuildInviteResponse({
|
||||
getGuildResponse,
|
||||
getGuildCounts,
|
||||
gatewayService,
|
||||
}: MapInviteToGuildInviteResponseParams): Promise<z.infer<typeof GuildInviteResponse>> {
|
||||
}: MapInviteToGuildInviteResponseParams): Promise<GuildInviteResponse> {
|
||||
if (!invite.guildId) {
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
@@ -117,7 +94,7 @@ export async function mapInviteToGuildInviteMetadataResponse({
|
||||
getGuildResponse,
|
||||
getGuildCounts,
|
||||
gatewayService,
|
||||
}: MapInviteToGuildInviteMetadataResponseParams): Promise<z.infer<typeof GuildInviteMetadataResponse>> {
|
||||
}: MapInviteToGuildInviteResponseParams): Promise<GuildInviteMetadataResponse> {
|
||||
const baseResponse = await mapInviteToGuildInviteResponse({
|
||||
invite,
|
||||
userCacheService,
|
||||
@@ -143,7 +120,7 @@ export async function mapInviteToGroupDmInviteResponse({
|
||||
getChannelResponse,
|
||||
getChannelSystem,
|
||||
getChannelMemberCount,
|
||||
}: MapInviteToGroupDmInviteResponseParams): Promise<z.infer<typeof GroupDmInviteResponse>> {
|
||||
}: MapInviteToGroupDmInviteResponseParams): Promise<GroupDmInviteResponse> {
|
||||
if (!invite.channelId) {
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
@@ -195,7 +172,7 @@ export async function mapInviteToGroupDmInviteMetadataResponse({
|
||||
getChannelResponse,
|
||||
getChannelSystem,
|
||||
getChannelMemberCount,
|
||||
}: MapInviteToGroupDmInviteMetadataResponseParams): Promise<z.infer<typeof GroupDmInviteMetadataResponse>> {
|
||||
}: MapInviteToGroupDmInviteResponseParams): Promise<GroupDmInviteMetadataResponse> {
|
||||
const baseResponse = await mapInviteToGroupDmInviteResponse({
|
||||
invite,
|
||||
userCacheService,
|
||||
|
||||
@@ -4,12 +4,13 @@ import {Headers} from '@fluxer/constants/src/Headers';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
|
||||
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import type {InstanceCaptchaProvider} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory';
|
||||
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import type {Context} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
import {Config} from '../Config';
|
||||
import type {InstanceCaptchaEffectiveConfig, InstanceCaptchaProvider} from '../instance/InstanceConfigRepository';
|
||||
import type {InstanceCaptchaEffectiveConfig} from '../instance/InstanceConfigRepository';
|
||||
import type {User} from '../models/User';
|
||||
import {accountPolicyContactHasCapability} from '../risk/AccountPolicyService';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
|
||||
@@ -78,7 +78,7 @@ export function ResponseType<T extends ZodType>(
|
||||
): MiddlewareHandler<HonoEnv> {
|
||||
const {skipValidation = false, allowNoContent = false} = options ?? {};
|
||||
return async (ctx, next) => {
|
||||
ctx.set('responseSchema' as keyof HonoEnv['Variables'], schema);
|
||||
ctx.set('responseSchema', schema);
|
||||
await next();
|
||||
if (skipValidation || !Config.dev.validateResponses) {
|
||||
return;
|
||||
@@ -90,35 +90,19 @@ export function ResponseType<T extends ZodType>(
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenAPIMetadata {
|
||||
operationId: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
responseSchema: ZodType | null;
|
||||
requestSchema?: ZodType;
|
||||
requestFormSchema?: ZodType;
|
||||
requestBodyRequired?: boolean;
|
||||
statusCode?: number | Array<number>;
|
||||
security?: SecurityScheme | Array<SecurityScheme>;
|
||||
tags: string | Array<string>;
|
||||
deprecated?: boolean;
|
||||
externalDocs?: {
|
||||
url: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type SecurityScheme = 'botToken' | 'oauth2Token' | 'bearerToken' | 'sessionToken' | 'adminApiKey';
|
||||
|
||||
interface OpenAPIRouteMetadata {
|
||||
export interface OpenAPIRouteMetadata {
|
||||
operationId: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
responseSchema: ZodType | null;
|
||||
responseContentType?: string;
|
||||
requestSchema?: ZodType;
|
||||
requestFormSchema?: ZodType;
|
||||
requestBodyRequired?: boolean;
|
||||
statusCode?: number | Array<number>;
|
||||
bodylessStatusCodes?: Array<number>;
|
||||
security?: SecurityScheme | Array<SecurityScheme>;
|
||||
tags: string | Array<string>;
|
||||
deprecated?: boolean;
|
||||
@@ -130,6 +114,7 @@ interface OpenAPIRouteMetadata {
|
||||
|
||||
interface OpenAPIOptions {
|
||||
description: string;
|
||||
responseContentType?: string;
|
||||
}
|
||||
|
||||
function validateOperationId(operationId: string): void {
|
||||
@@ -140,20 +125,8 @@ function validateOperationId(operationId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSecurityToArray(
|
||||
security?: SecurityScheme | Array<SecurityScheme>,
|
||||
): Array<SecurityScheme> | undefined {
|
||||
if (!security) return undefined;
|
||||
return Array.isArray(security) ? security : [security];
|
||||
}
|
||||
|
||||
function normalizeTagsToArray(tags: string | Array<string>): Array<string> {
|
||||
return Array.isArray(tags) ? tags : [tags];
|
||||
}
|
||||
|
||||
function normalizeStatusCodeToArray(statusCode?: number | Array<number>): Array<number> | undefined {
|
||||
if (!statusCode) return undefined;
|
||||
return Array.isArray(statusCode) ? statusCode : [statusCode];
|
||||
function toArray<T>(value: T | Array<T>): Array<T> {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
export function OpenAPI(metadata: OpenAPIRouteMetadata): MiddlewareHandler<HonoEnv>;
|
||||
@@ -186,33 +159,28 @@ export function OpenAPI(
|
||||
summary: summary!,
|
||||
description: options.description,
|
||||
responseSchema,
|
||||
responseContentType: options.responseContentType,
|
||||
tags: [],
|
||||
};
|
||||
} else {
|
||||
metadata = operationIdOrMetadata;
|
||||
}
|
||||
validateOperationId(metadata.operationId);
|
||||
const {statusCode, security, tags, deprecated, externalDocs} = metadata;
|
||||
const {statusCode, security, tags, bodylessStatusCodes, responseContentType} = metadata;
|
||||
const schema = metadata.responseSchema;
|
||||
return async (ctx, next) => {
|
||||
const fullMetadata: OpenAPIMetadata = {
|
||||
operationId: metadata.operationId,
|
||||
summary: metadata.summary,
|
||||
description: metadata.description,
|
||||
responseSchema: schema,
|
||||
requestSchema: metadata.requestSchema,
|
||||
requestFormSchema: metadata.requestFormSchema,
|
||||
requestBodyRequired: metadata.requestBodyRequired,
|
||||
statusCode: statusCode ? normalizeStatusCodeToArray(statusCode) : undefined,
|
||||
security: security ? normalizeSecurityToArray(security) : undefined,
|
||||
tags: normalizeTagsToArray(tags),
|
||||
deprecated,
|
||||
externalDocs,
|
||||
const fullMetadata: OpenAPIRouteMetadata = {
|
||||
...metadata,
|
||||
statusCode: statusCode === undefined ? undefined : toArray(statusCode),
|
||||
security: security === undefined ? undefined : toArray(security),
|
||||
tags: toArray(tags),
|
||||
};
|
||||
ctx.set('openapiMetadata' as keyof HonoEnv['Variables'], fullMetadata);
|
||||
ctx.set('responseSchema' as keyof HonoEnv['Variables'], schema);
|
||||
const mediaType = responseContentType?.split(';', 1)[0]?.trim().toLowerCase();
|
||||
const hasJsonResponse = mediaType === undefined || mediaType === 'application/json';
|
||||
return async (ctx, next) => {
|
||||
ctx.set('openapiMetadata', fullMetadata);
|
||||
ctx.set('responseSchema', schema);
|
||||
await next();
|
||||
if (!schema || !Config.dev.validateResponses) {
|
||||
if (!schema || !hasJsonResponse || !Config.dev.validateResponses || bodylessStatusCodes?.includes(ctx.res.status)) {
|
||||
return;
|
||||
}
|
||||
await validateAndRewriteResponse(ctx, schema);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {Logger} from '@fluxer/logger/src/Logger';
|
||||
import {SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {Hono} from 'hono';
|
||||
import {afterAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
import {z} from 'zod';
|
||||
import {Config} from '../../Config';
|
||||
import type {HonoEnv} from '../../types/HonoEnv';
|
||||
@@ -12,12 +12,14 @@ import {OpenAPI, ResponseType} from '../ResponseTypeMiddleware';
|
||||
const SnowflakeResponse = z.object({id: SnowflakeType});
|
||||
|
||||
describe('ResponseTypeMiddleware', () => {
|
||||
const originalValidateResponses = Config.dev.validateResponses;
|
||||
|
||||
beforeEach(() => {
|
||||
Config.dev.validateResponses = true;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Config.dev.validateResponses = true;
|
||||
afterEach(() => {
|
||||
Config.dev.validateResponses = originalValidateResponses;
|
||||
});
|
||||
|
||||
test('serializes SnowflakeType response transforms as JSON strings', async () => {
|
||||
@@ -50,9 +52,25 @@ describe('ResponseTypeMiddleware', () => {
|
||||
expect(await response.json()).toEqual({id: '123456789012345678'});
|
||||
});
|
||||
|
||||
test('rejects mismatching responses while validation is enabled', async () => {
|
||||
test.each([
|
||||
undefined,
|
||||
'application/json',
|
||||
'application/json; charset=utf-8',
|
||||
'Application/JSON; charset=utf-8',
|
||||
])('rejects mismatching responses while validation is enabled (content type: %s)', async (responseContentType) => {
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.get('/snowflake', ResponseType(SnowflakeResponse), (ctx) => ctx.json({id: 'not-a-snowflake'}));
|
||||
const middleware =
|
||||
responseContentType === undefined
|
||||
? ResponseType(SnowflakeResponse)
|
||||
: OpenAPI({
|
||||
operationId: 'get_invalid_snowflake_test',
|
||||
summary: 'Get invalid snowflake',
|
||||
description: 'Returns an invalid snowflake to verify JSON response validation.',
|
||||
responseSchema: SnowflakeResponse,
|
||||
responseContentType,
|
||||
tags: ['Tests'],
|
||||
});
|
||||
app.get('/snowflake', middleware, (ctx) => ctx.json({id: 'not-a-snowflake'}));
|
||||
const errorLoggerSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
@@ -106,4 +124,67 @@ describe('ResponseTypeMiddleware', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({id: 'not-a-snowflake', extra: 'kept'});
|
||||
});
|
||||
|
||||
test('normalizes route metadata while preserving anonymous access and bodyless statuses', async () => {
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.get(
|
||||
'/metadata',
|
||||
OpenAPI({
|
||||
operationId: 'get_metadata_test',
|
||||
summary: 'Get route metadata',
|
||||
description: 'Exposes normalized metadata for this route.',
|
||||
responseSchema: z.object({
|
||||
statusCode: z.array(z.number()),
|
||||
bodylessStatusCodes: z.array(z.number()),
|
||||
security: z.array(z.string()),
|
||||
tags: z.array(z.string()),
|
||||
}),
|
||||
statusCode: [200, 302],
|
||||
bodylessStatusCodes: [302],
|
||||
security: [],
|
||||
tags: 'Tests',
|
||||
}),
|
||||
(ctx) => {
|
||||
const metadata = ctx.get('openapiMetadata');
|
||||
return ctx.json({
|
||||
statusCode: metadata?.statusCode,
|
||||
bodylessStatusCodes: metadata?.bodylessStatusCodes,
|
||||
security: metadata?.security,
|
||||
tags: metadata?.tags,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const response = await app.request('/metadata');
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
statusCode: [200, 302],
|
||||
bodylessStatusCodes: [302],
|
||||
security: [],
|
||||
tags: ['Tests'],
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves raw JSON file bytes under a binary response contract', async () => {
|
||||
const body = '{ "file": "contents" }';
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.get(
|
||||
'/artifact',
|
||||
OpenAPI({
|
||||
operationId: 'get_artifact_test',
|
||||
summary: 'Get artifact file',
|
||||
description: 'Returns file bytes without treating JSON artifacts as structured API responses.',
|
||||
responseSchema: z.file(),
|
||||
responseContentType: '*/*',
|
||||
statusCode: 200,
|
||||
tags: ['Tests'],
|
||||
}),
|
||||
() => new Response(body, {headers: {'Content-Type': 'application/json'}}),
|
||||
);
|
||||
|
||||
const response = await app.request('/artifact');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('application/json');
|
||||
expect(await response.text()).toBe(body);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {GuildID, UserID} from '../BrandedTypes';
|
||||
import type {GuildAuditLogRow} from '../database/types/GuildTypes';
|
||||
@@ -11,7 +12,7 @@ export class GuildAuditLog {
|
||||
readonly logId: bigint;
|
||||
readonly userId: UserID;
|
||||
readonly targetId: string | null;
|
||||
readonly actionType: number;
|
||||
readonly actionType: AuditLogActionType;
|
||||
readonly reason: string | null;
|
||||
readonly options: Map<string, string>;
|
||||
readonly changes: GuildAuditLogChange | null;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user