mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a285cbb11 | ||
|
|
6d600990fe | ||
|
|
e1bc6c2f7e | ||
|
|
3e79530389 | ||
|
|
d0c84b3d9b | ||
|
|
3affd295e8 | ||
|
|
7b15e5be0f | ||
|
|
adab646d1e | ||
|
|
de1fd95a99 | ||
|
|
4bc5593f9f | ||
|
|
172791316b |
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,
|
||||
|
||||
+6373
-5187
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 {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
}
|
||||
.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_1: Some(generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
}),
|
||||
..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 {
|
||||
phrase: phrase.to_owned(),
|
||||
}
|
||||
.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_3: Some(generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
}),
|
||||
..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 {
|
||||
category: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
url: url.to_owned(),
|
||||
}
|
||||
.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_4: Some(generated_types::BanUrlRequest {
|
||||
category: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
url: url.to_owned(),
|
||||
}),
|
||||
..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 {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}
|
||||
.into(),
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_5: Some(generated_types::BanUrlDomainRequest {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}),
|
||||
..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 {
|
||||
category: None,
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
notes: None,
|
||||
reason: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}
|
||||
.into(),
|
||||
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,
|
||||
}),
|
||||
..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 {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
task:
|
||||
generated_types::UpdateUserFlagsAdminBulkJobCreateRequestTask::UpdateUserFlags,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateUserFlags {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
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 {
|
||||
add_flags: add_flags.to_vec(),
|
||||
remove_flags: remove_flags.to_vec(),
|
||||
task: generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequestTask::UpdateSuspiciousActivityFlags,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateSuspiciousActivityFlags {
|
||||
add_flags: add_flags.to_vec(),
|
||||
remove_flags: remove_flags.to_vec(),
|
||||
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 {
|
||||
add_features: guild_features(add_features),
|
||||
guild_ids: snowflakes(guild_ids),
|
||||
remove_features: guild_features(remove_features),
|
||||
task: generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequestTask::UpdateGuildFeatures,
|
||||
},
|
||||
);
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateGuildFeatures {
|
||||
add_features: guild_features(add_features),
|
||||
guild_ids: snowflakes(guild_ids),
|
||||
remove_features: guild_features(remove_features),
|
||||
};
|
||||
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 {
|
||||
guild_id: snowflake(guild_id),
|
||||
task:
|
||||
generated_types::AddGuildMembersAdminBulkJobCreateRequestTask::AddGuildMembers,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
let body = generated_types::AdminBulkJobCreateRequest::AddGuildMembers {
|
||||
guild_id: snowflake(guild_id),
|
||||
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,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
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)?,
|
||||
),
|
||||
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),
|
||||
},
|
||||
);
|
||||
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)?,
|
||||
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> {
|
||||
|
||||
+109
-40
@@ -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 {
|
||||
let status = response.status().as_u16();
|
||||
let text = response.text().await.map_err(|error| {
|
||||
ApiError::Network(format!("failed to read error response body: {error}"))
|
||||
})?;
|
||||
Err(ApiError::Http {
|
||||
status,
|
||||
message: text,
|
||||
})
|
||||
return Ok(response);
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let message = response.text().await.map_err(|error| {
|
||||
ApiError::Network(format!("failed to read error response body: {error}"))
|
||||
})?;
|
||||
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
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||
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()))?
|
||||
};
|
||||
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,16 +13,17 @@ 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(|e| ApiError::Parse(e.to_string()))?,
|
||||
.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
|
||||
.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::{
|
||||
@@ -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 {
|
||||
reason: reason
|
||||
.map(generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
status: generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestStatus::Approved,
|
||||
},
|
||||
);
|
||||
let guild_id = snowflake(guild_id);
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Approved {
|
||||
reason: reason
|
||||
.map(generated_types::DiscoveryReviewReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
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,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
status:
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestStatus::Rejected,
|
||||
},
|
||||
);
|
||||
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()))?,
|
||||
};
|
||||
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,
|
||||
@@ -18,6 +20,10 @@ pub struct InstanceConfigResponse {
|
||||
pub integrations: InstanceIntegrationsResponse,
|
||||
#[serde(default)]
|
||||
pub media: InstanceMediaResponse,
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -436,6 +442,125 @@ impl VoiceE2eeScope {
|
||||
}
|
||||
}
|
||||
|
||||
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::None,
|
||||
Self::Standard,
|
||||
Self::Gate,
|
||||
Self::Speex,
|
||||
Self::Rnnoise,
|
||||
Self::Gtcrn,
|
||||
Self::DeepFilter,
|
||||
];
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None (pass-through)",
|
||||
Self::Standard => "Standard (WebRTC)",
|
||||
Self::Gate => "Noise gate",
|
||||
Self::Speex => "Speex",
|
||||
Self::Rnnoise => "RNNoise",
|
||||
Self::Gtcrn => "GTCRN",
|
||||
Self::DeepFilter => "DeepFilterNet",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionGuildOverride {
|
||||
pub guild_id: String,
|
||||
pub backend: NoiseSuppressionBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct VoiceNoiseSuppressionConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub default_backend: NoiseSuppressionBackend,
|
||||
pub enabled_backends: Vec<NoiseSuppressionBackend>,
|
||||
pub allow_user_override: bool,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
|
||||
pub stereo_enabled: bool,
|
||||
pub suppression_strength: u32,
|
||||
}
|
||||
|
||||
impl Default for VoiceNoiseSuppressionConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
default_backend: NoiseSuppressionBackend::Standard,
|
||||
enabled_backends: NoiseSuppressionBackend::ALL.to_vec(),
|
||||
allow_user_override: true,
|
||||
rollout_basis_points: 0,
|
||||
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: 80,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_backend: Option<NoiseSuppressionBackend>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_user_override: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stereo_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppression_strength: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
pub poll_interval_seconds: u64,
|
||||
pub poll_jitter_percent: u32,
|
||||
}
|
||||
|
||||
impl Default for ExperimentDeliveryConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_interval_seconds: 300,
|
||||
poll_jitter_percent: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ExperimentDeliveryConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub poll_interval_seconds: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub poll_jitter_percent: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InstanceRegistrationResponse {
|
||||
pub mode: RegistrationMode,
|
||||
@@ -525,6 +650,10 @@ pub struct InstanceConfigUpdateRequest {
|
||||
pub integrations: Option<InstanceIntegrationsUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media: Option<InstanceMediaUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -826,3 +955,89 @@ pub struct CreateRegistrationUrlResponse {
|
||||
pub code: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::generated::types as generated_types;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_backend_choices_use_the_generated_wire_contract() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(NoiseSuppressionBackend::ALL).expect("serializable backends"),
|
||||
json!([
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
"speex",
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
])
|
||||
);
|
||||
assert!(serde_json::from_value::<NoiseSuppressionBackend>(json!("deepfilter")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
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!(
|
||||
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_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!(
|
||||
value,
|
||||
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
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::{
|
||||
@@ -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,9 +102,10 @@ 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())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
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()
|
||||
.create_admin_voice_server(®ion_id, &body)
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ use crate::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
@@ -16,8 +17,10 @@ use crate::{
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, PremiumMode, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VoiceE2eeScope,
|
||||
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
|
||||
VoiceNoiseSuppressionGuildOverride,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -200,6 +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" => 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"),
|
||||
@@ -401,12 +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,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,15 +443,216 @@ 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,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
|
||||
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_MAX_POLL_JITTER_PERCENT: u32 = 50;
|
||||
|
||||
fn parse_form_number<T>(
|
||||
form: &MultiValueForm,
|
||||
key: &str,
|
||||
label: &str,
|
||||
min: T,
|
||||
max: T,
|
||||
) -> Result<Option<T>, String>
|
||||
where
|
||||
T: std::str::FromStr + Ord + std::fmt::Display,
|
||||
{
|
||||
let Some(raw) = form.first(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
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,
|
||||
) -> 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 {
|
||||
!value.is_empty()
|
||||
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
|
||||
let candidate = candidate.trim();
|
||||
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;
|
||||
}
|
||||
if ids.len() == VOICE_NS_MAX_TARGETED_USERS {
|
||||
return Err(format!(
|
||||
"{label} must contain at most {VOICE_NS_MAX_TARGETED_USERS} unique IDs"
|
||||
));
|
||||
}
|
||||
ids.push(candidate.to_owned());
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_guild_overrides(
|
||||
value: &str,
|
||||
) -> Result<Vec<VoiceNoiseSuppressionGuildOverride>, String> {
|
||||
let mut overrides: Vec<VoiceNoiseSuppressionGuildOverride> = Vec::new();
|
||||
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) {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
|
||||
));
|
||||
}
|
||||
let backend = backend.trim().parse().map_err(|_| {
|
||||
format!("Guild overrides line {line_number} must name a supported backend")
|
||||
})?;
|
||||
if let Some(existing) = overrides
|
||||
.iter()
|
||||
.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;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
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.contains(backend))
|
||||
.collect();
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
voice_noise_suppression: Some(VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("voice_ns_enabled")),
|
||||
default_backend: form
|
||||
.first("voice_ns_default_backend")
|
||||
.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_form_number(
|
||||
form,
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
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_form_number(
|
||||
form,
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression strength",
|
||||
0,
|
||||
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
|
||||
)?,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
experiment_delivery: Some(ExperimentDeliveryConfigUpdateRequest {
|
||||
poll_interval_seconds: parse_form_number(
|
||||
form,
|
||||
"experiment_delivery_poll_interval_seconds",
|
||||
"Poll interval",
|
||||
EXPERIMENT_MIN_POLL_INTERVAL_SECONDS,
|
||||
EXPERIMENT_MAX_POLL_INTERVAL_SECONDS,
|
||||
)?,
|
||||
poll_jitter_percent: parse_form_number(
|
||||
form,
|
||||
"experiment_delivery_poll_jitter_percent",
|
||||
"Poll jitter",
|
||||
0,
|
||||
EXPERIMENT_MAX_POLL_JITTER_PERCENT,
|
||||
)?,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let mode = match form.first("registration_mode") {
|
||||
Some("approval") => Some(RegistrationMode::Approval),
|
||||
@@ -454,27 +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,
|
||||
..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"),
|
||||
@@ -491,18 +690,13 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
|
||||
legal: None,
|
||||
registration: None,
|
||||
}),
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: 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,
|
||||
@@ -512,17 +706,12 @@ fn build_app_legal_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
|
||||
}),
|
||||
registration: None,
|
||||
}),
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: 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,
|
||||
@@ -531,9 +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,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,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,
|
||||
@@ -561,8 +744,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
services,
|
||||
deferred_phone_gate,
|
||||
}),
|
||||
integrations: None,
|
||||
media: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,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"),
|
||||
@@ -671,7 +848,7 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
|
||||
keys: bluesky_keys,
|
||||
}),
|
||||
}),
|
||||
media: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,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")),
|
||||
@@ -700,6 +871,7 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
renew_window_days: form.parse_u32("media_attachment_decay_renew_window_days"),
|
||||
}),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,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,
|
||||
@@ -740,8 +908,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
|
||||
services: None,
|
||||
deferred_phone_gate: None,
|
||||
}),
|
||||
integrations: None,
|
||||
media: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1066,6 +1233,399 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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=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).expect("valid form");
|
||||
let update = request
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.allow_user_override, Some(true));
|
||||
assert_eq!(update.stereo_enabled, Some(false));
|
||||
assert_eq!(
|
||||
update.default_backend,
|
||||
Some(NoiseSuppressionBackend::Rnnoise)
|
||||
);
|
||||
assert_eq!(
|
||||
update.enabled_backends,
|
||||
Some(vec![
|
||||
NoiseSuppressionBackend::None,
|
||||
NoiseSuppressionBackend::DeepFilter
|
||||
])
|
||||
);
|
||||
assert_eq!(update.rollout_basis_points, Some(10_000));
|
||||
assert_eq!(update.suppression_strength, Some(100));
|
||||
assert_eq!(update.rollout_salt, Some("voice-ns-v2".to_owned()));
|
||||
}
|
||||
|
||||
#[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).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]
|
||||
fn build_voice_noise_suppression_update_reads_user_id_textareas() {
|
||||
let form = MultiValueForm::parse(
|
||||
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!(
|
||||
update.included_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000001".to_owned(),
|
||||
"1500000000000000002".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000003".to_owned(),
|
||||
"1500000000000000004".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[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 ", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec![
|
||||
"1".to_owned(),
|
||||
"2".to_owned(),
|
||||
"3".to_owned(),
|
||||
"4".to_owned(),
|
||||
"5".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
|
||||
assert_eq!(
|
||||
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() {
|
||||
for value in [
|
||||
"abc",
|
||||
"12a",
|
||||
"-1",
|
||||
"1.0",
|
||||
"999999999999999999999",
|
||||
"<script>",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(
|
||||
&format!("123,{value}"),
|
||||
"Included user IDs"
|
||||
)
|
||||
.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_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(&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()));
|
||||
assert_eq!(
|
||||
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 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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_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());
|
||||
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![
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
},
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000002".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Speex,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(&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).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"experiment_delivery": {}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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!(
|
||||
build_experiment_delivery_update(&form).expect_err("invalid number"),
|
||||
message,
|
||||
"{key}={value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"experiment_delivery": {"poll_interval_seconds": interval, "poll_jitter_percent": jitter}})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_accepts_padded_numbers() {
|
||||
let form = MultiValueForm::parse(
|
||||
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));
|
||||
assert_eq!(update.poll_jitter_percent, Some(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_limit_rule_values_reads_checked_limit_keys() {
|
||||
let form = MultiValueForm::parse(b"message_send=1&traits%5B%5D=trial");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -152,7 +152,9 @@ const GUILD_FEATURES: &[&str] = &[
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_EMOJI_ENABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
"CLONE_STICKER_ENABLED",
|
||||
"DETACHED_BANNER",
|
||||
"INVITE_SPLASH",
|
||||
"INVITES_DISABLED",
|
||||
@@ -178,6 +180,16 @@ const GUILD_FEATURES: &[&str] = &[
|
||||
"VERY_LARGE_GUILD",
|
||||
];
|
||||
|
||||
const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
|
||||
|
||||
fn guild_feature_label(feature: &str) -> String {
|
||||
if DEPRECATED_GUILD_FEATURES.contains(&feature) {
|
||||
format!("{feature} (deprecated, removal only)")
|
||||
} else {
|
||||
feature.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let admin_acls = auth
|
||||
@@ -222,6 +234,18 @@ fn flag_checkbox_grid(prefix: &str, flags: &[&str]) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn guild_feature_checkbox_grid(prefix: &str, include_deprecated: bool) -> Markup {
|
||||
html! {
|
||||
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
|
||||
@for feature in GUILD_FEATURES {
|
||||
@if include_deprecated || !DEPRECATED_GUILD_FEATURES.contains(feature) {
|
||||
(checkbox(prefix, feature, &guild_feature_label(feature), false, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn user_flag_checkbox_grid(prefix: &str) -> Markup {
|
||||
html! {
|
||||
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
|
||||
@@ -304,13 +328,13 @@ fn bulk_update_guild_features_section(base: &str, csrf_token: &str) -> Markup {
|
||||
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
|
||||
"Features to Add"
|
||||
}
|
||||
(flag_checkbox_grid("add_features[]", GUILD_FEATURES))
|
||||
(guild_feature_checkbox_grid("add_features[]", false))
|
||||
}
|
||||
div {
|
||||
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
|
||||
"Features to Remove"
|
||||
}
|
||||
(flag_checkbox_grid("remove_features[]", GUILD_FEATURES))
|
||||
(guild_feature_checkbox_grid("remove_features[]", true))
|
||||
}
|
||||
(form_field_group("Custom features to add", "custom_add_features", false, None,
|
||||
Some("Comma-separated list of custom features not in the standard set."),
|
||||
@@ -411,3 +435,26 @@ fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn add_grid_offers_only_the_opt_in_clone_features() {
|
||||
let markup = guild_feature_checkbox_grid("add_features[]", false).into_string();
|
||||
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
|
||||
assert!(markup.contains(r#"value="CLONE_STICKER_ENABLED""#));
|
||||
assert!(!markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
|
||||
assert!(!markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_grid_can_clear_the_deprecated_clone_features() {
|
||||
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
|
||||
assert!(markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
|
||||
assert!(markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
|
||||
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, removal only)"));
|
||||
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ const GUILD_FEATURES: &[&str] = &[
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_EMOJI_ENABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
"CLONE_STICKER_ENABLED",
|
||||
"DETACHED_BANNER",
|
||||
"INVITE_SPLASH",
|
||||
"INVITES_DISABLED",
|
||||
@@ -47,6 +49,16 @@ const GUILD_FEATURES: &[&str] = &[
|
||||
|
||||
const HOSTED_ONLY: &[&str] = &["VISIONARY", "VIP_VOICE"];
|
||||
|
||||
const DEPRECATED_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
|
||||
|
||||
fn feature_label(feature: &str) -> String {
|
||||
if DEPRECATED_FEATURES.contains(&feature) {
|
||||
format!("{feature} (deprecated, no longer enforced)")
|
||||
} else {
|
||||
feature.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn features_tab(
|
||||
config: &AdminConfig,
|
||||
guild: &GuildInfo,
|
||||
@@ -88,7 +100,7 @@ pub fn features_tab(
|
||||
(checkbox(
|
||||
"features[]",
|
||||
feature,
|
||||
feature,
|
||||
&feature_label(feature),
|
||||
guild.features.iter().any(|f| f == feature),
|
||||
true,
|
||||
))
|
||||
@@ -142,7 +154,7 @@ fn features_tab_readonly(guild: &GuildInfo, features_list: &[&str]) -> Markup {
|
||||
@for feature in &enabled {
|
||||
span class="inline-flex items-center rounded-full bg-green-100 \
|
||||
px-2.5 py-0.5 text-xs font-medium text-green-800" {
|
||||
(feature)
|
||||
(feature_label(feature))
|
||||
}
|
||||
}
|
||||
@for feature in &custom {
|
||||
@@ -169,3 +181,56 @@ fn filtered_features() -> Vec<&'static str> {
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn guild_with_features(features: &[&str]) -> GuildInfo {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "1600000000000000001",
|
||||
"name": "Test Guild",
|
||||
"icon": null,
|
||||
"banner": null,
|
||||
"owner_id": "1500000000000000001",
|
||||
"owner_username": null,
|
||||
"owner_global_name": null,
|
||||
"owner_discriminator": null,
|
||||
"features": features,
|
||||
"nsfw_level": null,
|
||||
"nsfw": null,
|
||||
"content_warning_level": null,
|
||||
"content_warning_text": null,
|
||||
"description": null,
|
||||
"vanity_url_code": null,
|
||||
}))
|
||||
.expect("guild fixture")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_offers_the_opt_in_clone_features() {
|
||||
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
|
||||
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
|
||||
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
|
||||
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_keeps_the_deprecated_clone_features_clearable() {
|
||||
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_DISABLED"));
|
||||
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_DISABLED"));
|
||||
assert_eq!(
|
||||
feature_label("CLONE_EMOJI_DISABLED"),
|
||||
"CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"
|
||||
);
|
||||
assert_eq!(feature_label("CLONE_EMOJI_ENABLED"), "CLONE_EMOJI_ENABLED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_view_marks_a_stale_flag_as_deprecated() {
|
||||
let guild = guild_with_features(&["CLONE_EMOJI_DISABLED", "CLONE_STICKER_ENABLED"]);
|
||||
let markup = features_tab_readonly(&guild, GUILD_FEATURES).into_string();
|
||||
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"));
|
||||
assert!(markup.contains("CLONE_STICKER_ENABLED"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse,
|
||||
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
|
||||
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
|
||||
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
|
||||
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceNoiseSuppressionConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -42,6 +44,17 @@ fn format_decimal(value: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_count_hint(count: usize, cap: usize) -> Markup {
|
||||
html! {
|
||||
p class="text-xs text-neutral-500" {
|
||||
(count) " of " (cap) " stored"
|
||||
@if count >= cap {
|
||||
" (at the cap; remove an entry before adding another)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn number_field(
|
||||
name: &str,
|
||||
label: &str,
|
||||
@@ -134,6 +147,8 @@ pub fn instance_config_page(
|
||||
"Gateway rollout behavior and the limit rules applied to users and guilds.",
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
} @else {
|
||||
@@ -953,6 +968,254 @@ fn gateway_rollout_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn voice_noise_suppression_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if voice_noise_suppression.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let backend_labels =
|
||||
NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label()));
|
||||
let backend_options = backend_labels
|
||||
.iter()
|
||||
.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))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
section_card_with_description(
|
||||
"Voice Noise Suppression",
|
||||
"Pick which noise suppression backend targeted clients load in voice calls, and how many \
|
||||
of them are targeted. While the master switch below is off nothing on this form reaches \
|
||||
any client: every user keeps the audio pipeline they have today, whatever the rest of \
|
||||
these fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (voice_noise_suppression.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"voice_ns_enabled",
|
||||
"true",
|
||||
"Serve noise suppression assignments to clients",
|
||||
voice_noise_suppression.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
feature is inert and keeps its current behavior, so the rollout, targeting \
|
||||
and override fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Backends" }
|
||||
(select_input(
|
||||
"voice_ns_default_backend",
|
||||
"Default Backend",
|
||||
&backend_options,
|
||||
&voice_noise_suppression.default_backend.to_string(),
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"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.to_string(),
|
||||
backend.label(),
|
||||
voice_noise_suppression.enabled_backends.contains(&backend),
|
||||
true,
|
||||
))
|
||||
}
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Backends clients are allowed to load. Unticking one withdraws it from \
|
||||
every user, including anyone who picked it themselves."
|
||||
}
|
||||
(checkbox(
|
||||
"voice_ns_allow_user_override",
|
||||
"true",
|
||||
"Let users pick their own backend from the ticked list",
|
||||
voice_noise_suppression.allow_user_override,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Applies only to users who are already targeted. It never pulls anyone \
|
||||
into the rollout."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&voice_noise_suppression.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"voice_ns_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&voice_noise_suppression.rollout_salt,
|
||||
"voice-ns-v1",
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.included_user_ids.len(),
|
||||
VOICE_NS_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
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" {
|
||||
(textarea_input(
|
||||
"voice_ns_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.excluded_user_ids.len(),
|
||||
VOICE_NS_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, so this is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" }
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_guild_overrides",
|
||||
"Guild Overrides",
|
||||
"1600000000000000001=rnnoise\n1600000000000000002=deep_filter",
|
||||
&guild_overrides,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.guild_overrides.len(),
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
|
||||
(checkbox(
|
||||
"voice_ns_stereo_enabled",
|
||||
"true",
|
||||
"Process stereo input instead of downmixing to mono",
|
||||
voice_noise_suppression.stereo_enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Costs more CPU on the client. Leave off unless you are testing stereo \
|
||||
capture."
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(number_field(
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression Strength",
|
||||
&voice_noise_suppression.suppression_strength.to_string(),
|
||||
Some(0), Some(100), "1",
|
||||
Some("How aggressively the backend removes noise, 0 to 100. Higher values cut more background but chew more of the voice."),
|
||||
))
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Voice Noise Suppression Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
experiment_delivery: &ExperimentDeliveryConfigResponse,
|
||||
) -> Markup {
|
||||
section_card_with_description(
|
||||
"Experiment Delivery",
|
||||
"How often every client revalidates its experiment assignments. This is instance-wide \
|
||||
and covers every experiment, not just the one above. Raising the interval sheds \
|
||||
request volume and makes a change take longer to reach a client. Raising the jitter \
|
||||
spreads a fleet that has synchronised on one tick back out across the interval.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_experiment_delivery"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(number_field(
|
||||
"experiment_delivery_poll_interval_seconds",
|
||||
"Assignment Poll Interval (s)",
|
||||
&experiment_delivery.poll_interval_seconds.to_string(),
|
||||
Some(60), Some(86400), "1",
|
||||
Some("How often a client re-reads its assignments, 60 to 86400 seconds. Lower values pick up changes sooner at the cost of more requests."),
|
||||
))
|
||||
(number_field(
|
||||
"experiment_delivery_poll_jitter_percent",
|
||||
"Assignment Poll Jitter (%)",
|
||||
&experiment_delivery.poll_jitter_percent.to_string(),
|
||||
Some(0), Some(50), "1",
|
||||
Some("How far each client spreads its poll around the interval, 0 to 50 percent. Raise it to break up a fleet that polls on the same tick, set it to 0 for an exact interval."),
|
||||
))
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Experiment Delivery Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn registration_config_section(
|
||||
config: &AdminConfig,
|
||||
csrf_token: &str,
|
||||
@@ -1522,3 +1785,49 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::types::VoiceNoiseSuppressionGuildOverride;
|
||||
|
||||
fn rendered_voice_noise_suppression_section(
|
||||
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||
) -> String {
|
||||
voice_noise_suppression_section("/admin", "csrf", voice_noise_suppression).into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_shows_list_counts_and_caps() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
guild_overrides: vec![VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
}],
|
||||
..VoiceNoiseSuppressionConfigResponse::default()
|
||||
};
|
||||
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(markup.contains("1 of 200 stored"));
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
included_user_ids: (0..VOICE_NS_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect(),
|
||||
..VoiceNoiseSuppressionConfigResponse::default()
|
||||
};
|
||||
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
|
||||
assert!(markup.contains("1000 of 1000 stored"));
|
||||
assert!(markup.contains("at the cap"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -430,6 +431,8 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
&[
|
||||
"/instance-config?action=update_gateway_rollout",
|
||||
"/instance-config?action=update_sso",
|
||||
"/instance-config?action=update_voice_noise_suppression",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
),
|
||||
];
|
||||
@@ -903,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,
|
||||
@@ -917,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",
|
||||
@@ -942,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,
|
||||
@@ -954,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 {
|
||||
@@ -1081,6 +1097,32 @@ fn instance_config() -> Value {
|
||||
"max_concurrent_guild_starts": 16,
|
||||
"voice_e2ee_scope": "guild_feature_only"
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"default_backend": "standard",
|
||||
"enabled_backends": [
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
"speex",
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
],
|
||||
"allow_user_override": true,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "voice-ns-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"stereo_enabled": false,
|
||||
"suppression_strength": 80
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"poll_interval_seconds": 300,
|
||||
"poll_jitter_percent": 15
|
||||
},
|
||||
"registration": registration_config(),
|
||||
"self_hosted": false
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#[path = "parity/mod.rs"]
|
||||
mod parity_support;
|
||||
|
||||
use fluxer_admin::api::generated::types::{LookupGuildResponse, SearchGuildsResponse};
|
||||
use parity_support::{
|
||||
TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture,
|
||||
html_normalizer, rust_server,
|
||||
@@ -48,6 +49,31 @@ fn html_normalizer_allows_intentional_rust_markup_fixes() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_search_fixture_matches_the_generated_response_contract() {
|
||||
let response: SearchGuildsResponse =
|
||||
serde_json::from_str(include_str!("parity/fixtures/api/search_guilds.json"))
|
||||
.expect("guild search fixture must match the generated response contract");
|
||||
assert_eq!(response.guilds.len(), 1);
|
||||
let guild = &response.guilds[0];
|
||||
assert_eq!(guild.name, "Parity Guild");
|
||||
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_lookup_fixture_matches_the_generated_response_contract() {
|
||||
let response: LookupGuildResponse =
|
||||
serde_json::from_str(include_str!("parity/fixtures/api/lookup_guild.json"))
|
||||
.expect("guild lookup fixture must match the generated response contract");
|
||||
let guild = response
|
||||
.guild
|
||||
.expect("guild lookup fixture must contain a guild");
|
||||
assert_eq!(String::from(guild.name), "Parity Guild");
|
||||
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
|
||||
assert_eq!(guild.channels.len(), 1);
|
||||
assert_eq!(guild.channels[0].content_warning_level.as_deref(), Some(&0));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<(), Box<dyn Error>> {
|
||||
let api_server = api_fixtures::ApiFixtureServer::start_default()
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"mfa_level": 0,
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": null,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null,
|
||||
"explicit_content_filter": 2,
|
||||
"default_message_notifications": 1,
|
||||
@@ -36,7 +36,7 @@
|
||||
"parent_id": null,
|
||||
"nsfw": false,
|
||||
"nsfw_override": null,
|
||||
"content_warning_level": null,
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null,
|
||||
"url": null
|
||||
}
|
||||
@@ -51,7 +51,6 @@
|
||||
"hoist": false,
|
||||
"mentionable": false
|
||||
}
|
||||
],
|
||||
"description": "Guild used by admin parity fixtures."
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,8 @@
|
||||
"features": ["COMMUNITY", "DISCOVERABLE"],
|
||||
"nsfw_level": 0,
|
||||
"nsfw": false,
|
||||
"content_warning_level": null,
|
||||
"content_warning_text": null,
|
||||
"description": "Guild used by admin parity fixtures.",
|
||||
"vanity_url_code": "parity"
|
||||
"content_warning_level": 0,
|
||||
"content_warning_text": null
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
|
||||
@@ -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,15 +1,16 @@
|
||||
// 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,
|
||||
} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationError';
|
||||
import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
|
||||
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
|
||||
import {getCookie} from 'hono/cookie';
|
||||
import type {ZodError, ZodTypeAny} from 'zod';
|
||||
import {type core, type input, type output, ZodObject, ZodOptional, type ZodSafeParseResult, type ZodType} from 'zod';
|
||||
import {requireRequestJsonBody} from './utils/RequestJsonBody';
|
||||
import {initializeFluxerErrorMap} from './ZodErrorMap';
|
||||
|
||||
@@ -19,12 +20,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,72 +27,37 @@ 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};
|
||||
}
|
||||
|
||||
function convertEmptyValuesToNull(obj: unknown, isRoot = true): unknown {
|
||||
function convertEmptyValuesToNull(obj: unknown, schema?: core.$ZodType, isRoot = true): unknown {
|
||||
while (schema instanceof ZodOptional) schema = schema.unwrap();
|
||||
if (schema && schemaMetadata.get(schema)?.preserveEmptyValues) return obj;
|
||||
if (typeof obj === 'string' && obj === '') return null;
|
||||
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, false));
|
||||
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, undefined, false));
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
if (isEmptyObject(obj) && !isRoot) return null;
|
||||
const shape = schema instanceof ZodObject ? schema.shape : undefined;
|
||||
const processed = Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => [key, convertEmptyValuesToNull(value, false)]),
|
||||
Object.entries(obj).map(([key, value]) => [
|
||||
key,
|
||||
convertEmptyValuesToNull(value, shape && Object.hasOwn(shape, key) ? shape[key] : undefined, false),
|
||||
]),
|
||||
);
|
||||
if (!isRoot && Object.values(processed).every((value) => value === null)) return null;
|
||||
return processed;
|
||||
@@ -106,24 +66,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 +85,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 +95,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 +113,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
|
||||
? {
|
||||
@@ -248,7 +199,7 @@ export const Validator = <
|
||||
if (options.pre) {
|
||||
value = await options.pre(value, c, target);
|
||||
}
|
||||
const transformedValue = convertEmptyValuesToNull(value);
|
||||
const transformedValue = convertEmptyValuesToNull(value, schema);
|
||||
const result = await schema.safeParseAsync(transformedValue);
|
||||
if (options.post) {
|
||||
const hookResult = await options.post({...result, target}, c);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -14,14 +14,16 @@ import {
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
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,
|
||||
@@ -51,9 +53,19 @@ function omitUndefinedFields<T extends object>(value: T): Partial<T> {
|
||||
|
||||
async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
const instanceConfigRepository = getInstanceConfigRepository();
|
||||
const [ssoConfig, gatewayRollout, registrationConfig, registrationUrls, pendingRegistrations] = await Promise.all([
|
||||
const [
|
||||
ssoConfig,
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
pendingRegistrations,
|
||||
] = await Promise.all([
|
||||
instanceConfigRepository.getSsoConfig(),
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
instanceConfigRepository.getPendingRegistrations(),
|
||||
@@ -83,6 +95,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
redirect_uri: deriveSsoRedirectUri(Config.endpoints.webApp),
|
||||
},
|
||||
gateway_rollout: gatewayRollout,
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
urls: registrationUrls,
|
||||
@@ -214,6 +228,26 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
await instanceConfigRepository.setGatewayRolloutConfig(validated);
|
||||
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,
|
||||
...patch,
|
||||
config_version: currentNoiseSuppression.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
|
||||
const validated = ExperimentDeliveryConfigSchema.parse({
|
||||
...currentExperimentDelivery,
|
||||
...data.experiment_delivery,
|
||||
});
|
||||
await instanceConfigRepository.setExperimentDeliveryConfig(validated);
|
||||
}
|
||||
if (data.sso) {
|
||||
const sso = data.sso;
|
||||
const current = await instanceConfigRepository.getSsoConfig({includeSecret: true});
|
||||
@@ -404,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') {
|
||||
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,
|
||||
);
|
||||
if (subjectType === 'all') {
|
||||
throw InputValidationError.create(
|
||||
'subject_type',
|
||||
'subject_type must name user or guild when subject_id is supplied',
|
||||
);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {DELETED_USER_ID} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {UserRepository} from '../../user/repositories/UserRepository';
|
||||
|
||||
const SYNTHETIC_USER_IDS = ['0', String(DELETED_USER_ID)];
|
||||
|
||||
const MUTATIONS: Array<{verb: 'put' | 'patch' | 'delete'; path: string; acl: string; body?: unknown}> = [
|
||||
{verb: 'patch', path: 'username', acl: AdminACLs.USER_UPDATE_USERNAME, body: {username: 'takenover'}},
|
||||
{verb: 'patch', path: 'email', acl: AdminACLs.USER_UPDATE_EMAIL, body: {email: '[email protected]'}},
|
||||
{verb: 'patch', path: 'flags', acl: AdminACLs.USER_UPDATE_FLAGS, body: {add_flags: ['STAFF'], remove_flags: []}},
|
||||
{verb: 'put', path: 'acls', acl: AdminACLs.ACL_SET_USER, body: {acls: [AdminACLs.AUTHENTICATE]}},
|
||||
{verb: 'put', path: 'ban', acl: AdminACLs.USER_TEMP_BAN, body: {duration_hours: 1, reason: 'test'}},
|
||||
{verb: 'put', path: 'deletion', acl: AdminACLs.USER_DELETE, body: {delay_days: 1}},
|
||||
{verb: 'delete', path: 'profile-fields', acl: AdminACLs.USER_UPDATE_PROFILE, body: {fields: ['bio']}},
|
||||
{verb: 'put', path: 'bot-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {bot: true}},
|
||||
{verb: 'put', path: 'system-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {system: true}},
|
||||
];
|
||||
|
||||
const CASES = SYNTHETIC_USER_IDS.flatMap((userId) =>
|
||||
MUTATIONS.map((mutation) => ({
|
||||
userId,
|
||||
...mutation,
|
||||
name: `${mutation.verb.toUpperCase()} ${mutation.path} on ${userId}`,
|
||||
})),
|
||||
);
|
||||
|
||||
describe('admin mutations against synthetic accounts', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
test.each(CASES)('$name is rejected and writes no row', async (testCase) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, testCase.acl]);
|
||||
const request = createBuilder(harness, `${admin.token}`)[testCase.verb](
|
||||
`/admin/users/${testCase.userId}/${testCase.path}`,
|
||||
);
|
||||
if (testCase.body !== undefined) {
|
||||
request.body(testCase.body);
|
||||
}
|
||||
await request.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_USER').execute();
|
||||
expect(await new UserRepository().listUsers([createUserID(BigInt(testCase.userId))])).toEqual([]);
|
||||
});
|
||||
test.each(SYNTHETIC_USER_IDS)('reading user %s stays a 200 with no results', async (userId) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
|
||||
const result = await createBuilder<{users: Array<{id: string}>}>(harness, `${admin.token}`)
|
||||
.get(`/admin/users/${userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(result.users).toEqual([]);
|
||||
});
|
||||
test.each(['%200', '0%0A', '%091', '1%20'])('padded user id %s is rejected the same way', async (encodedUserId) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_UPDATE_USERNAME]);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.patch(`/admin/users/${encodedUserId}/username`)
|
||||
.body({username: 'takenover'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_USER')
|
||||
.execute();
|
||||
expect(await new UserRepository().listUsers([createUserID(0n), createUserID(DELETED_USER_ID)])).toEqual([]);
|
||||
});
|
||||
test.each(SYNTHETIC_USER_IDS)('force-adding user %s to a guild is rejected', async (userId) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.GUILD_FORCE_ADD_MEMBER]);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.put(`/admin/guilds/1/members/${userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_USER')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DELETED_USER_ID} from '@fluxer/constants/src/UserConstants';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {createGuildID, createUserID, type UserID} from '../../BrandedTypes';
|
||||
import type {IChannelRepository} from '../../channel/IChannelRepository';
|
||||
import {UserMessageDeletionService} from '../../channel/services/message/UserMessageDeletionService';
|
||||
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
|
||||
import {GuildMemberOperationsService} from '../../guild/services/member/GuildMemberOperationsService';
|
||||
import type {IPurgeQueue} from '../../infrastructure/BunnyPurgeQueue';
|
||||
import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
|
||||
const SYNTHETIC_USER_IDS: Array<[string, UserID]> = [
|
||||
['0', createUserID(0n)],
|
||||
['1', createUserID(DELETED_USER_ID)],
|
||||
];
|
||||
|
||||
const GUILD_ID = createGuildID(7000n);
|
||||
|
||||
function unusableDependency(): object {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('dependency touched before the synthetic user guard ran');
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createMessageDeletionService(): UserMessageDeletionService {
|
||||
const unusable = unusableDependency();
|
||||
return new UserMessageDeletionService({
|
||||
channelRepository: unusable as IChannelRepository,
|
||||
gatewayService: unusable as IGatewayService,
|
||||
storageService: unusable as IStorageService,
|
||||
purgeQueue: unusable as IPurgeQueue,
|
||||
});
|
||||
}
|
||||
|
||||
function createGuildMemberOperationsService(): GuildMemberOperationsService {
|
||||
const guildRepository = {
|
||||
async findUnique() {
|
||||
return {id: GUILD_ID, features: new Set(), memberCount: 0};
|
||||
},
|
||||
async getMember() {
|
||||
return null;
|
||||
},
|
||||
} as unknown as IGuildRepositoryAggregate;
|
||||
const unusable = unusableDependency();
|
||||
return new GuildMemberOperationsService(
|
||||
guildRepository,
|
||||
unusable as never,
|
||||
unusable as never,
|
||||
unusable as never,
|
||||
unusable as never,
|
||||
unusable as never,
|
||||
unusable as never,
|
||||
unusable as never,
|
||||
unusable as never,
|
||||
unusable as never,
|
||||
unusable as never,
|
||||
unusable as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('service level guards for synthetic accounts', () => {
|
||||
test.each(SYNTHETIC_USER_IDS)('deleteUserMessagesBulk refuses user %s', async (_label, userId) => {
|
||||
await expect(createMessageDeletionService().deleteUserMessagesBulk(userId)).rejects.toBeInstanceOf(
|
||||
UnknownUserError,
|
||||
);
|
||||
});
|
||||
test.each(SYNTHETIC_USER_IDS)('addUserToGuild refuses user %s', async (_label, userId) => {
|
||||
await expect(
|
||||
createGuildMemberOperationsService().addUserToGuild(
|
||||
{
|
||||
userId,
|
||||
guildId: GUILD_ID,
|
||||
skipBanCheck: true,
|
||||
skipGuildLimitCheck: true,
|
||||
skipRiskGate: true,
|
||||
requestCache: new Map(),
|
||||
} as never,
|
||||
unusableDependency() as never,
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnknownUserError);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type {APIConfig} from '../config/APIConfig';
|
||||
import {ConnectionController} from '../connection/ConnectionController';
|
||||
import {DonationController} from '../donation/DonationController';
|
||||
import {DownloadController} from '../download/DownloadController';
|
||||
import {ExperimentController} from '../experiment/ExperimentController';
|
||||
import {FavoriteGifController} from '../favorite_gif/FavoriteGifController';
|
||||
import {FavoriteMemeController} from '../favorite_meme/FavoriteMemeController';
|
||||
import {GatewayController} from '../gateway/GatewayController';
|
||||
@@ -50,6 +51,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
InstanceController(routes);
|
||||
OpenAPIController(routes);
|
||||
DownloadController(routes);
|
||||
ExperimentController(routes);
|
||||
FavoriteGifController(routes);
|
||||
FavoriteMemeController(routes);
|
||||
InviteController(routes);
|
||||
|
||||
@@ -52,7 +52,18 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
|
||||
);
|
||||
applyMiddlewareStack(routes, {
|
||||
requestId: {},
|
||||
cors: {origins: corsOrigins, exposedHeaders: [HttpHeaders.X_FLUXER_VERSION]},
|
||||
cors: {
|
||||
origins: corsOrigins,
|
||||
allowedHeaders: [
|
||||
HttpHeaders.CONTENT_TYPE,
|
||||
HttpHeaders.AUTHORIZATION,
|
||||
'X-Requested-With',
|
||||
'Accept-Language',
|
||||
HttpHeaders.X_REQUEST_ID,
|
||||
HttpHeaders.IF_NONE_MATCH,
|
||||
],
|
||||
exposedHeaders: [HttpHeaders.X_FLUXER_VERSION, HttpHeaders.ETAG],
|
||||
},
|
||||
skipLogger: true,
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {
|
||||
WebAuthnAuthenticateRequest,
|
||||
WebAuthnMfaRequest,
|
||||
WebAuthnRegisterRequest,
|
||||
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {
|
||||
UserSettingsUpdateRequest,
|
||||
UserUpdateWithVerificationRequest,
|
||||
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import {Hono} from 'hono';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {ZodType} from 'zod';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
const credential = {
|
||||
id: 'credential-id',
|
||||
rawId: 'credential-id',
|
||||
type: 'public-key',
|
||||
clientExtensionResults: {},
|
||||
};
|
||||
const registration = {
|
||||
...credential,
|
||||
response: {clientDataJSON: 'client-data', attestationObject: 'attestation'},
|
||||
};
|
||||
const authentication = {
|
||||
...credential,
|
||||
response: {clientDataJSON: '', authenticatorData: '', signature: '', userHandle: ''},
|
||||
};
|
||||
|
||||
async function validateRequest(schema: ZodType, body: unknown): Promise<Response> {
|
||||
const app = new Hono();
|
||||
app.onError((error, ctx) => {
|
||||
if (error instanceof InputValidationError) {
|
||||
return ctx.json({errors: error.getLocalizedErrors()}, 400);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
app.post('/validate', Validator('json', schema), (ctx) => Response.json(ctx.req.valid('json')));
|
||||
return app.request('/validate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: 'registration',
|
||||
schema: WebAuthnRegisterRequest,
|
||||
field: 'response',
|
||||
response: registration,
|
||||
body: {challenge: 'challenge', name: 'Passkey'},
|
||||
challenge: 'challenge',
|
||||
},
|
||||
{
|
||||
name: 'authentication',
|
||||
schema: WebAuthnAuthenticateRequest,
|
||||
field: 'response',
|
||||
response: authentication,
|
||||
body: {challenge: 'challenge'},
|
||||
challenge: 'challenge',
|
||||
},
|
||||
{
|
||||
name: 'MFA login',
|
||||
schema: WebAuthnMfaRequest,
|
||||
field: 'response',
|
||||
response: authentication,
|
||||
body: {challenge: 'challenge', ticket: 'ticket'},
|
||||
challenge: 'challenge',
|
||||
},
|
||||
{
|
||||
name: 'sudo account update',
|
||||
schema: UserUpdateWithVerificationRequest,
|
||||
field: 'webauthn_response',
|
||||
response: authentication,
|
||||
body: {mfa_method: 'webauthn', webauthn_challenge: 'challenge'},
|
||||
challenge: 'webauthn_challenge',
|
||||
},
|
||||
])('$name validation', ({schema, field, response, body, challenge}) => {
|
||||
it.each([
|
||||
{name: 'empty results', value: {}},
|
||||
{
|
||||
name: 'nested empty values and unknown extensions',
|
||||
value: {credProps: {}, appid: false, futureExtension: {empty: {}, text: '', entries: [{}, '', null, 0]}},
|
||||
},
|
||||
])('preserves $name', async ({value: clientExtensionResults}) => {
|
||||
const input = {...body, [field]: {...response, clientExtensionResults}};
|
||||
const result = await validateRequest(schema, input);
|
||||
expect(result.status).toBe(200);
|
||||
expect(await result.json()).toEqual(input);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'missing results', value: undefined, path: ''},
|
||||
{name: 'null results', value: null, path: ''},
|
||||
{name: 'array results', value: [], path: ''},
|
||||
{name: 'non-boolean appid', value: {appid: 'false'}, path: '.appid'},
|
||||
{name: 'null credential properties', value: {credProps: null}, path: '.credProps'},
|
||||
])('rejects $name', async ({value, path}) => {
|
||||
const result = await validateRequest(schema, {...body, [field]: {...response, clientExtensionResults: value}});
|
||||
expect(result.status).toBe(400);
|
||||
expect(await result.json()).toMatchObject({errors: [{path: `${field}.clientExtensionResults${path}`}]});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{key: 'id', value: undefined},
|
||||
{key: 'rawId', value: undefined},
|
||||
{key: 'type', value: 'invalid'},
|
||||
{key: 'response', value: null},
|
||||
])('rejects malformed credential $key', async ({key, value}) => {
|
||||
const result = await validateRequest(schema, {...body, [field]: {...response, [key]: value}});
|
||||
expect(result.status).toBe(400);
|
||||
expect(await result.json()).toMatchObject({errors: [{path: `${field}.${key}`}]});
|
||||
});
|
||||
|
||||
it('still validates the challenge outside the credential', async () => {
|
||||
const result = await validateRequest(schema, {...body, [field]: response, [challenge]: ''});
|
||||
expect(result.status).toBe(400);
|
||||
expect(await result.json()).toMatchObject({errors: [{path: challenge}]});
|
||||
});
|
||||
});
|
||||
|
||||
it('retains profile clearing beside a WebAuthn sudo response', async () => {
|
||||
const result = await validateRequest(UserUpdateWithVerificationRequest, {
|
||||
webauthn_response: authentication,
|
||||
avatar: '',
|
||||
banner: '',
|
||||
bio: '',
|
||||
});
|
||||
expect(result.status).toBe(200);
|
||||
expect(await result.json()).toEqual({webauthn_response: authentication, avatar: null, banner: null, bio: null});
|
||||
});
|
||||
|
||||
it('retains empty-object clearing outside WebAuthn credentials', async () => {
|
||||
const result = await validateRequest(UserSettingsUpdateRequest, {custom_status: {}});
|
||||
expect(result.status).toBe(200);
|
||||
expect(await result.json()).toEqual({custom_status: null});
|
||||
});
|
||||
|
||||
it.each(['__proto__', 'constructor', 'toString'])('does not treat unknown key %s as a schema field', async (field) => {
|
||||
const result = await validateRequest(UserUpdateWithVerificationRequest, {[field]: {}, avatar: ''});
|
||||
expect(result.status).toBe(200);
|
||||
expect(await result.json()).toEqual({avatar: null});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -4,12 +4,14 @@ import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {ChannelID, GuildID, MessageID, UserID} from '../../../BrandedTypes';
|
||||
import {createChannelID} from '../../../BrandedTypes';
|
||||
import type {IChannelRepository} from '../../../channel/IChannelRepository';
|
||||
import {assertMutableUserId} from '../../../constants/Core';
|
||||
import type {IPurgeQueue} from '../../../infrastructure/BunnyPurgeQueue';
|
||||
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||
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 +52,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;
|
||||
@@ -81,6 +75,7 @@ export class UserMessageDeletionService {
|
||||
}
|
||||
|
||||
async deleteUserMessagesBulk(userId: UserID, options: BulkDeleteUserMessagesOptions = {}): Promise<number> {
|
||||
assertMutableUserId(userId);
|
||||
const {beforeTimestamp = Number.POSITIVE_INFINITY, channelIdAllowlist, onProgress} = options;
|
||||
Logger.debug({userId, beforeTimestamp}, 'Starting bulk user message deletion');
|
||||
const messagesByChannel = await this.collectUserMessages(userId, beforeTimestamp, channelIdAllowlist);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DELETED_USER_ID} from '@fluxer/constants/src/UserConstants';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import {createUserID, type UserID} from '../BrandedTypes';
|
||||
|
||||
export const SYSTEM_USER_ID = createUserID(0n);
|
||||
@@ -8,3 +9,9 @@ export const SYSTEM_USER_ID = createUserID(0n);
|
||||
export function isSyntheticUserId(userId: UserID): boolean {
|
||||
return userId === SYSTEM_USER_ID || userId === DELETED_USER_ID;
|
||||
}
|
||||
|
||||
export function assertMutableUserId(userId: UserID): void {
|
||||
if (isSyntheticUserId(userId)) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {LoginRequired} from '../middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../RateLimitConfig';
|
||||
import type {HonoApp} from '../types/HonoEnv';
|
||||
import {entityTagMatches} from '../utils/EntityTag';
|
||||
|
||||
export function ExperimentController(app: HonoApp) {
|
||||
app.get(
|
||||
'/experiments',
|
||||
RateLimitMiddleware(RateLimitConfigs.DEFAULT),
|
||||
LoginRequired,
|
||||
OpenAPI({
|
||||
operationId: 'get_experiments',
|
||||
summary: 'Get the experiment assignments',
|
||||
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, 304],
|
||||
security: ['bearerToken', 'sessionToken', 'botToken'],
|
||||
tags: ['Experiments'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, voiceConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
]);
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, ctx.get('user').id.toString()),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
ctx.header(HttpHeaders.ETAG, etag);
|
||||
ctx.header(HttpHeaders.CACHE_CONTROL, 'private, no-cache');
|
||||
ctx.header('Vary', 'Authorization');
|
||||
const ifNoneMatch = ctx.req.header(HttpHeaders.IF_NONE_MATCH);
|
||||
if (ifNoneMatch !== undefined && entityTagMatches(ifNoneMatch, etag)) {
|
||||
return ctx.body(null, 304);
|
||||
}
|
||||
return ctx.json(body);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
type ExperimentAssignmentsResponse,
|
||||
type ExperimentDeliveryConfigResponse,
|
||||
readVoiceNoiseSuppressionAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
|
||||
const NOT_MODIFIED = 304;
|
||||
const ENDPOINT = '/experiments';
|
||||
|
||||
describe('GET /experiments', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('rejects an unauthenticated caller', async () => {
|
||||
await createBuilderWithoutAuth(harness).get(ENDPOINT).expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
|
||||
it('returns the default delivery cadence and the inert assignment while the feature is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(body).toEqual({
|
||||
poll_interval_seconds: DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
assignments: {voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the inert assignment while the stored config is disabled but populated', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: false,
|
||||
config_version: 9,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(body.assignments.voice_noise_suppression).toEqual({
|
||||
...INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
config_version: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates the voice assignment key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'voice_noise_suppression')).toBe(true);
|
||||
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setExperimentDeliveryConfig({
|
||||
poll_interval_seconds: 7200,
|
||||
poll_jitter_percent: 45,
|
||||
});
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 3,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(body.poll_interval_seconds).toBe(7200);
|
||||
expect(body.poll_jitter_percent).toBe(45);
|
||||
expect(body.assignments.voice_noise_suppression).toMatchObject({enabled: true, config_version: 3});
|
||||
expect(body.assignments.voice_noise_suppression).not.toHaveProperty('poll_interval_seconds');
|
||||
expect(body.assignments.voice_noise_suppression).not.toHaveProperty('poll_jitter_percent');
|
||||
});
|
||||
|
||||
it('echoes the config version and resolves the caller through the allowlist', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const untargeted = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
default_backend: 'rnnoise',
|
||||
rollout_basis_points: 0,
|
||||
included_user_ids: [targeted.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.voice_noise_suppression).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
user_targeted: true,
|
||||
backend: 'rnnoise',
|
||||
source: 'user_rule',
|
||||
});
|
||||
|
||||
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(untargetedBody.assignments.voice_noise_suppression).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
user_targeted: false,
|
||||
backend: null,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('revalidates with a strong etag and answers 304 when nothing changed', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.executeWithResponse();
|
||||
const etag = first.response.headers.get('etag');
|
||||
expect(etag).toMatch(/^"[0-9a-f]{64}"$/);
|
||||
expect(first.response.headers.get('cache-control')).toBe('private, no-cache');
|
||||
expect(first.response.headers.get('vary')).toBe('Authorization');
|
||||
|
||||
const revalidated = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.header('If-None-Match', etag as string)
|
||||
.expect(NOT_MODIFIED)
|
||||
.executeWithResponse();
|
||||
expect(revalidated.response.status).toBe(NOT_MODIFIED);
|
||||
expect(revalidated.json).toBeUndefined();
|
||||
expect(revalidated.response.headers.get('etag')).toBe(etag);
|
||||
});
|
||||
|
||||
it('lets a cross-origin client send If-None-Match and read the etag back', async () => {
|
||||
const preflight = await harness.requestJson({path: ENDPOINT, method: 'OPTIONS'});
|
||||
|
||||
expect(preflight.headers.get('access-control-allow-headers')).toContain('If-None-Match');
|
||||
expect(preflight.headers.get('access-control-expose-headers')).toContain('ETag');
|
||||
});
|
||||
|
||||
it('serves a fresh body once the voice config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.executeWithResponse();
|
||||
const staleEtag = first.response.headers.get('etag') as string;
|
||||
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const refreshed = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.header('If-None-Match', staleEtag)
|
||||
.executeWithResponse();
|
||||
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
|
||||
expect(refreshed.json?.assignments.voice_noise_suppression).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
user_targeted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a fresh body once the delivery config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.executeWithResponse();
|
||||
const staleEtag = first.response.headers.get('etag') as string;
|
||||
|
||||
await getInstanceConfigRepository().setExperimentDeliveryConfig({
|
||||
poll_interval_seconds: 1800,
|
||||
poll_jitter_percent: 5,
|
||||
});
|
||||
|
||||
const refreshed = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.header('If-None-Match', staleEtag)
|
||||
.executeWithResponse();
|
||||
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
|
||||
expect(refreshed.json?.poll_interval_seconds).toBe(1800);
|
||||
expect(refreshed.json?.poll_jitter_percent).toBe(5);
|
||||
});
|
||||
|
||||
it('bumps the config version on every admin update without the client sending one', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {enabled: true, rollout_basis_points: 10000}})
|
||||
.execute();
|
||||
expect(afterFirst.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {suppression_strength: 42}})
|
||||
.execute();
|
||||
expect(afterSecond.voice_noise_suppression).toMatchObject({config_version: 2, enabled: true});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.voice_noise_suppression).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
suppression_strength: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the config version alone for an admin update that sets no field', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {enabled: true}})
|
||||
.execute();
|
||||
expect(afterFirst.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterEmpty = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {}})
|
||||
.execute();
|
||||
expect(afterEmpty.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterUndefined = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {enabled: undefined}})
|
||||
.execute();
|
||||
expect(afterUndefined.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
});
|
||||
|
||||
it('serves the delivery cadence an admin set through the instance config', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const updated = await createBuilder<{experiment_delivery: ExperimentDeliveryConfigResponse}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({experiment_delivery: {poll_interval_seconds: 3600}})
|
||||
.execute();
|
||||
expect(updated.experiment_delivery).toEqual({
|
||||
poll_interval_seconds: 3600,
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.poll_interval_seconds).toBe(3600);
|
||||
expect(body.poll_jitter_percent).toBe(DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT);
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user