Compare commits

...
Author SHA1 Message Date
HampusandGitHub 805acf4e5e feat(ban): accept delete_message_seconds and app options (#2086) 2026-08-29 16:22:12 +02:00
HampusandGitHub 9f099a9127 fix(api): keep the saved placeholder on sent memes (#2085) 2026-08-29 16:04:58 +02:00
HampusandGitHub 4d15c39cd7 fix(app): give mature media blur the real media dimensions (#2084) 2026-08-29 16:04:55 +02:00
HampusandGitHub 827451d12d fix(unfurl): trust curated klipy media without rescanning (#2083) 2026-08-29 16:04:52 +02:00
HampusandGitHub 03603662c6 fix(media-proxy): require corroborating frames for nsfw (#2082) 2026-08-29 16:04:48 +02:00
HampusandGitHub 34eb10cd88 fix(markdown): only open code fences at line start (#2081) 2026-08-29 15:59:04 +02:00
HampusandGitHub 82941c08c9 fix(api): enforce single-role MFA, fix reindex and NCMEC scans (#2080) 2026-08-29 15:31:42 +02:00
HampusandGitHub 8d21c97d08 fix(admin): sort session imports to satisfy rustfmt (#2079) 2026-08-29 15:05:04 +02:00
HampusandGitHub 71a56f590d fix(ci): read dependent load flags at the pe32+ offset (#2078) 2026-08-29 15:00:38 +02:00
HampusandGitHub 38297c4fe7 chore(api): drop knip-unused search and SSO exports (#2077) 2026-08-29 15:00:09 +02:00
HampusandGitHub cf7ec06d85 fix(api): enforce scoped perms, age gates, audit logs (#2076) 2026-08-29 14:55:13 +02:00
HampusandGitHub f2ea10f951 fix(api): harden ratelimit, SSRF, uploads and DM guards (#2074) 2026-08-29 14:55:09 +02:00
HampusandGitHub bbd93df239 fix(api): expire auth tokens and harden login checks (#2073) 2026-08-29 14:55:04 +02:00
HampusandGitHub 9a6ab93e01 fix(media-proxy): bound GIF decode and BMFF box walking (#2075) 2026-08-29 14:55:00 +02:00
HampusandGitHub 38eed7cce6 fix(gateway): restrict /_metrics to loopback callers (#2072) 2026-08-29 14:54:56 +02:00
HampusandGitHub 79064c3399 fix(admin): sign CSRF tokens and escape them in scripts (#2071) 2026-08-29 14:54:51 +02:00
HampusandGitHub 0496b2f530 fix(ci): reject path traversal in S3 prefix downloads (#2070) 2026-08-29 14:54:37 +02:00
HampusandGitHub 9d0be1ebd1 fix(desktop): drop game capture injection and pin dll search (#2069) 2026-08-29 14:26:28 +02:00
HampusandGitHub 9ad026b8ce fix(app): repair theme CSS sync persistence and data loss (#2067) 2026-08-29 03:37:41 +02:00
HampusandGitHub 14de5971d5 chore(api): drop unused ELEVATED_MFA_PERMISSIONS export (#2066) 2026-08-29 03:12:24 +02:00
HampusandGitHub 5474be3efa fix(api): close auth, billing and authorization bypasses (#2065) 2026-08-29 02:59:38 +02:00
fluxer-ci[bot]andGitHub 9a54bbba2d chore(i18n): update public marketing catalogs (#2064) 2026-08-29 01:52:48 +02:00
fluxer-ci[bot]andGitHub 5a0110ccc8 chore(marketing): advance pointer 0c78170 → 5908507 (#2063) 2026-08-29 01:52:44 +02:00
HampusandGitHub d032d577bf fix(app-proxy): drop leaked canary debug cert from assetlinks (#2062) 2026-08-29 01:43:26 +02:00
HampusandGitHub 243954c9c5 test(api): use a future baseline in invoice-skip premium tests (#2061) 2026-08-29 01:22:29 +02:00
HampusandGitHub ba1be73389 fix(media-proxy): cap ISO-BMFF box walker recursion depth (#2059) 2026-08-29 01:04:46 +02:00
HampusandGitHub af3ad02962 fix(voice): default noise suppression to standard 2026-08-28 20:43:22 +02:00
210 changed files with 2792 additions and 12677 deletions
+8 -1
View File
@@ -11854,7 +11854,14 @@
"minimum": 0,
"maximum": 7,
"format": "int32",
"description": "Number of days of messages to delete from the banned user (0-7)"
"description": "Number of days of messages to delete from the banned user (0-7). Deprecated in favor of delete_message_seconds."
},
"delete_message_seconds": {
"type": "integer",
"minimum": 0,
"maximum": 604800,
"format": "int32",
"description": "Number of seconds of messages to delete for the banned user (0-604800, default 0)"
},
"reason": {"description": "The reason for the ban (max 512 characters)", "nullable": true, "type": "string"},
"ban_duration_seconds": {
+59 -61
View File
@@ -2,24 +2,43 @@
use axum::{
body::{Body, to_bytes},
extract::Request,
extract::{Request, State},
http::{HeaderValue, Method, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use rand::RngExt;
use crate::middleware::auth::AuthContext;
use crate::session::{create_csrf_token, verify_csrf_token};
use crate::state::AppState;
const CSRF_COOKIE_NAME: &str = "csrf_token";
pub const CSRF_FORM_FIELD: &str = "_csrf";
const CSRF_HEADER_NAME: &str = "x-csrf-token";
const TOKEN_LENGTH: usize = 32;
const HOST_CSRF_COOKIE_NAME: &str = "__Host-csrf_token";
const MAX_CSRF_FORM_BYTES: usize = 8 * 1024 * 1024;
const IGNORED_PATH_SUFFIXES: &[&str] = &["/oauth2_callback", "/auth/start"];
pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
let existing_token = extract_csrf_cookie(&request);
let token = existing_token.unwrap_or_else(generate_csrf_token);
pub async fn csrf_protection(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Response {
let config = state.config();
let secret = config.secret_key_base.clone();
let admin_endpoint = config.admin_endpoint.clone();
let is_production = config.is_production();
let user_id = request
.extensions()
.get::<AuthContext>()
.map(|ctx| ctx.session.user_id.clone())
.unwrap_or_default();
let token = extract_csrf_cookie(&request)
.filter(|cookie| verify_csrf_token(cookie, &user_id, &secret))
.unwrap_or_else(|| create_csrf_token(&user_id, &secret));
request.extensions_mut().insert(CsrfToken(token.clone()));
if matches!(
@@ -31,6 +50,9 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
.iter()
.any(|suffix| path.ends_with(suffix));
if !is_ignored {
if !is_same_site_request(&request, &admin_endpoint) {
return StatusCode::FORBIDDEN.into_response();
}
let header_token = extract_csrf_header(&request);
let query_token = extract_csrf_from_query(&request);
let mut submitted = query_token.or(header_token);
@@ -43,21 +65,24 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
request = restored_request;
submitted = body_token;
}
match submitted {
Some(ref submitted_token) if submitted_token == &token => {}
_ => {
return StatusCode::FORBIDDEN.into_response();
}
let accepted = submitted.as_deref().is_some_and(|submitted_token| {
submitted_token == token && verify_csrf_token(submitted_token, &user_id, &secret)
});
if !accepted {
return StatusCode::FORBIDDEN.into_response();
}
}
}
let mut response = next.run(request).await;
let cookie_value = format!(
"{}={}; Path=/; SameSite=Lax; HttpOnly",
CSRF_COOKIE_NAME, token
);
let cookie_name = if is_production {
HOST_CSRF_COOKIE_NAME
} else {
CSRF_COOKIE_NAME
};
let secure = if is_production { "; Secure" } else { "" };
let cookie_value = format!("{cookie_name}={token}; Path=/; SameSite=Lax; HttpOnly{secure}");
if let Ok(value) = HeaderValue::from_str(&cookie_value) {
response.headers_mut().append(header::SET_COOKIE, value);
}
@@ -69,7 +94,10 @@ fn extract_csrf_cookie(request: &Request) -> Option<String> {
let cookie_header = request.headers().get(header::COOKIE)?.to_str().ok()?;
for pair in cookie_header.split(';') {
let pair = pair.trim();
if let Some(value) = pair.strip_prefix("csrf_token=") {
let value = pair
.strip_prefix("__Host-csrf_token=")
.or_else(|| pair.strip_prefix("csrf_token="));
if let Some(value) = value {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
@@ -127,18 +155,22 @@ async fn extract_csrf_from_form_body(
Ok((request, token))
}
fn generate_csrf_token() -> String {
let mut rng = rand::rng();
let bytes: [u8; TOKEN_LENGTH] = rng.random();
hex_encode(&bytes)
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for byte in bytes {
s.push_str(&format!("{byte:02x}"));
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
if let Some(site) = request
.headers()
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
{
return matches!(site, "same-origin" | "same-site" | "none");
}
match request
.headers()
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
{
Some(origin) => origin == admin_endpoint,
None => true,
}
s
}
#[derive(Clone, Debug)]
@@ -156,40 +188,6 @@ pub fn get_csrf_token(request: &Request) -> String {
mod tests {
use super::*;
#[test]
fn generate_csrf_token_correct_length() {
let token = generate_csrf_token();
assert_eq!(
token.len(),
TOKEN_LENGTH * 2,
"token must be {} hex chars",
TOKEN_LENGTH * 2
);
}
#[test]
fn generate_csrf_token_is_valid_hex() {
let token = generate_csrf_token();
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token must contain only hex chars: {token}"
);
}
#[test]
fn generate_csrf_token_is_unique() {
let a = generate_csrf_token();
let b = generate_csrf_token();
assert_ne!(a, b, "consecutive tokens must differ");
}
#[test]
fn hex_encode_produces_correct_output() {
assert_eq!(hex_encode(&[0x00, 0xff, 0x0a]), "00ff0a");
assert_eq!(hex_encode(&[]), "");
assert_eq!(hex_encode(&[0xde, 0xad]), "dead");
}
#[test]
fn oauth2_callback_is_exempt() {
let exempt = IGNORED_PATH_SUFFIXES
+1 -1
View File
@@ -348,7 +348,7 @@ fn oauth_callback_page(config: &AdminConfig, code: Option<&str>, state: Option<&
)
}
fn json_string(value: &str) -> String {
pub(crate) fn json_string(value: &str) -> String {
serde_json::to_string(value)
.expect("JSON string serialization cannot fail")
.replace('<', "\\u003c")
+4 -1
View File
@@ -73,7 +73,10 @@ pub fn build_router(config: AdminConfig) -> Router {
.route("/", get(dashboard))
.route("/dashboard", get(dashboard))
.layer(from_fn(middleware::htmx::flash_redirect_to_toast))
.layer(from_fn(middleware::csrf::csrf_protection))
.layer(from_fn_with_state(
state.clone(),
middleware::csrf::csrf_protection,
))
.layer(from_fn(middleware::self_hosted::self_hosted_override))
.layer(from_fn_with_state(
state.clone(),
+31
View File
@@ -2,6 +2,7 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::{Hmac, KeyInit, Mac};
use rand::RngExt;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -78,6 +79,36 @@ fn verify_signature<'a>(signed_data: &'a str, secret_key: &str) -> Option<&'a st
if diff == 0 { Some(data) } else { None }
}
pub fn create_csrf_token(user_id: &str, secret_key: &str) -> String {
let mut rng = rand::rng();
let nonce: [u8; 16] = rng.random();
let payload = URL_SAFE_NO_PAD.encode(format!(
"{}:{}",
URL_SAFE_NO_PAD.encode(user_id.as_bytes()),
URL_SAFE_NO_PAD.encode(nonce)
));
sign_data(&payload, secret_key)
}
pub fn verify_csrf_token(token: &str, user_id: &str, secret_key: &str) -> bool {
let Some(data) = verify_signature(token, secret_key) else {
return false;
};
let Ok(decoded) = URL_SAFE_NO_PAD.decode(data.as_bytes()) else {
return false;
};
let Ok(payload) = String::from_utf8(decoded) else {
return false;
};
let Some((encoded_uid, _nonce)) = payload.split_once(':') else {
return false;
};
match URL_SAFE_NO_PAD.decode(encoded_uid.as_bytes()) {
Ok(uid_bytes) => uid_bytes == user_id.as_bytes(),
Err(_) => false,
}
}
pub const LEGACY_SESSION_COOKIE_NAME: &str = "session";
pub const SESSION_COOKIE_NAME: &str = "admin_session";
pub const SESSION_MAX_AGE: i64 = MAX_AGE_SECONDS as i64;
@@ -7,6 +7,7 @@ use super::media::user_avatar_url;
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
use super::user_display::format_user_display;
use crate::config::AdminConfig;
use crate::routes::auth::json_string;
pub struct Attachment {
pub id: String,
@@ -309,7 +310,7 @@ pub fn message_list(
}
pub fn message_deletion_script(csrf_token: &str) -> Markup {
let csrf = serde_json::to_string(csrf_token).unwrap_or_else(|_| "\"\"".into());
let csrf = json_string(csrf_token);
let script = r#"(function() {
var csrf = __CSRF__;
function bp() {
@@ -89,29 +89,77 @@ function isFqdnHostname(hostname: string): boolean {
return !/^\d+$/.test(topLevelDomain);
}
function parseIpv4MappedIpv6Address(ipv6Address: string): string | null {
function expandIpv6ToBytes(ipv6Address: string): Uint8Array | null {
const normalized = stripIpv6Brackets(ipv6Address.trim().toLowerCase());
if (!normalized.startsWith('::ffff:')) {
if (isIP(normalized) !== 6) {
return null;
}
const suffix = normalized.slice('::ffff:'.length);
if (isIP(suffix) === 4) {
return suffix;
let head = normalized;
let embeddedIpv4Octets: Array<number> | null = null;
const lastColonIndex = head.lastIndexOf(':');
const trailing = head.slice(lastColonIndex + 1);
if (trailing.includes('.')) {
if (isIP(trailing) !== 4) {
return null;
}
embeddedIpv4Octets = trailing.split('.').map((part) => Number.parseInt(part, 10));
head = `${head.slice(0, lastColonIndex + 1)}0:0`;
}
const groups = suffix.split(':');
if (groups.length !== 2) {
let groups: Array<string>;
if (head.indexOf('::') === -1) {
groups = head.split(':');
if (groups.length !== 8) {
return null;
}
} else {
const [beforePart, afterPart] = head.split('::');
const before = beforePart.length > 0 ? beforePart.split(':') : [];
const after = afterPart.length > 0 ? afterPart.split(':') : [];
const missing = 8 - before.length - after.length;
if (missing < 1) {
return null;
}
groups = [...before, ...new Array(missing).fill('0'), ...after];
}
const bytes = new Uint8Array(16);
for (let index = 0; index < 8; index += 1) {
const value = parseHexGroup(groups[index]);
if (value === null) {
return null;
}
bytes[index * 2] = (value >> 8) & 0xff;
bytes[index * 2 + 1] = value & 0xff;
}
if (embeddedIpv4Octets) {
bytes[12] = embeddedIpv4Octets[0];
bytes[13] = embeddedIpv4Octets[1];
bytes[14] = embeddedIpv4Octets[2];
bytes[15] = embeddedIpv4Octets[3];
}
return bytes;
}
function parseEmbeddedIpv4Address(ipv6Address: string): string | null {
const bytes = expandIpv6ToBytes(ipv6Address);
if (!bytes) {
return null;
}
const high = parseHexGroup(groups[0]);
const low = parseHexGroup(groups[1]);
if (high === null || low === null) {
return null;
const hasPrefix = (prefix: Array<number>): boolean => prefix.every((byte, index) => bytes[index] === byte);
const dottedQuadAt = (start: number): string =>
`${bytes[start]}.${bytes[start + 1]}.${bytes[start + 2]}.${bytes[start + 3]}`;
if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff])) {
return dottedQuadAt(12);
}
const octet1 = (high >> 8) & 0xff;
const octet2 = high & 0xff;
const octet3 = (low >> 8) & 0xff;
const octet4 = low & 0xff;
return `${octet1}.${octet2}.${octet3}.${octet4}`;
if (hasPrefix([0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0])) {
return dottedQuadAt(12);
}
if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) {
return dottedQuadAt(12);
}
if (hasPrefix([0x20, 0x02])) {
return dottedQuadAt(2);
}
return null;
}
function parseHexGroup(value: string | undefined): number | null {
@@ -128,9 +176,9 @@ function isBlockedIpAddress(address: string): boolean {
return blockedIpv4List.check(normalizedAddress, 'ipv4');
}
if (family === 6) {
const mappedIpv4 = parseIpv4MappedIpv6Address(normalizedAddress);
if (mappedIpv4) {
return blockedIpv4List.check(mappedIpv4, 'ipv4');
const embeddedIpv4 = parseEmbeddedIpv4Address(normalizedAddress);
if (embeddedIpv4) {
return blockedIpv4List.check(embeddedIpv4, 'ipv4');
}
return blockedIpv6List.check(normalizedAddress, 'ipv6');
}
@@ -133,6 +133,7 @@ export class AdminGuildMembershipService {
guildId,
targetId,
deleteMessageDays: data.delete_message_days,
deleteMessageSeconds: data.delete_message_seconds,
reason: data.reason ?? undefined,
banDurationSeconds: data.ban_duration_seconds ?? undefined,
skipGuildAuditLog: true,
+32 -19
View File
@@ -359,13 +359,36 @@ export async function login(
const MFA_TICKET_MAX_ATTEMPTS = 5;
const MFA_USER_MAX_ATTEMPTS = 10;
const MFA_USER_ATTEMPTS_WINDOW = seconds('15 minutes');
async function consumeMfaAttempt(
ctx: ApiContext,
{userId, ticket, field}: {userId: string; ticket: string; field: string},
): Promise<void> {
const {cache, rateLimit} = ctx.services;
const userLimit = await rateLimit.checkLimit({
identifier: `mfa:user:${userId}`,
maxAttempts: MFA_USER_MAX_ATTEMPTS,
windowMs: ms('15 minutes'),
});
if (!userLimit.allowed) {
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
}
const ticketLimit = await rateLimit.checkLimit({
identifier: `mfa:ticket:${ticket}`,
maxAttempts: MFA_TICKET_MAX_ATTEMPTS,
windowMs: ms('5 minutes'),
});
if (!ticketLimit.allowed) {
await cache.delete(`mfa-ticket:${ticket}`);
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
}
}
export async function loginMfaTotp(
ctx: ApiContext,
{code, ticket, request}: LoginMfaTotpParams,
): Promise<LoginTokenResult> {
const {users, cache} = ctx.services;
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.SESSION_TIMEOUT);
@@ -378,32 +401,19 @@ export async function loginMfaTotp(
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
}
const userAttemptsKey = `mfa-user-attempts:${user.id}`;
const userAttempts = (await cache.get<number>(userAttemptsKey)) ?? 0;
if (userAttempts >= MFA_USER_MAX_ATTEMPTS) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'});
const isValid = await AuthMfa.verifyMfaCode(ctx, {
userId: user.id,
mfaSecret: user.totpSecret,
code,
allowBackup: true,
});
const attemptsKey = `mfa-ticket-attempts:${ticket}`;
if (!isValid) {
await cache.set(userAttemptsKey, userAttempts + 1, MFA_USER_ATTEMPTS_WINDOW);
const attempts = ((await cache.get<number>(attemptsKey)) ?? 0) + 1;
if (attempts >= MFA_TICKET_MAX_ATTEMPTS) {
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
} else {
await cache.set(attemptsKey, attempts, seconds('5 minutes'));
}
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
await cache.delete(userAttemptsKey);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
@@ -415,7 +425,7 @@ export async function loginMfaWebAuthn(
ctx: ApiContext,
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
): Promise<LoginTokenResult> {
const {users, cache} = ctx.services;
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
@@ -425,8 +435,11 @@ export async function loginMfaWebAuthn(
throw new UnknownUserError();
}
AuthUtility.assertNonBotUser(ctx, user);
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
await cache.delete(`mfa-ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
+19 -1
View File
@@ -21,7 +21,7 @@ import {
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import {seconds} from 'itty-time';
import {ms, seconds} from 'itty-time';
import type {ApiContext} from '../ApiContext';
import {createUserID, type UserID} from '../BrandedTypes';
import {Logger} from '../Logger';
@@ -410,6 +410,20 @@ export async function generateWebAuthnOptionsForSudo(ctx: ApiContext, userId: Us
return options;
}
const SUDO_MFA_USER_MAX_ATTEMPTS = 10;
async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
const {rateLimit} = ctx.services;
const userLimit = await rateLimit.checkLimit({
identifier: `sudo-mfa:user:${userId}`,
maxAttempts: SUDO_MFA_USER_MAX_ATTEMPTS,
windowMs: ms('15 minutes'),
});
if (!userLimit.allowed) {
throw InputValidationError.fromCode('mfa_code', ValidationErrorCodes.INVALID_MFA_CODE);
}
}
export async function verifySudoMfa(
ctx: ApiContext,
params: SudoMfaVerificationParams,
@@ -427,7 +441,11 @@ export async function verifySudoMfa(
case 'totp': {
if (!code) return {success: false, error: 'TOTP code is required'};
if (!user.totpSecret) return {success: false, error: 'TOTP is not enabled'};
await consumeSudoMfaAttempt(ctx, userId);
const isValid = await verifyMfaCode(ctx, {userId, mfaSecret: user.totpSecret, code, allowBackup: true});
if (isValid) {
await ctx.services.rateLimit.resetLimit(`sudo-mfa:user:${userId}`);
}
return {success: isValid, error: isValid ? undefined : 'Invalid TOTP code'};
}
case 'webauthn': {
+1 -1
View File
@@ -267,7 +267,7 @@ export async function resetPassword(
},
user.toRow(),
);
await users.deleteAllAuthSessions(user.id);
await AuthSession.terminateAllUserSessions(ctx, user.id);
await users.deletePasswordResetToken(data.token);
const hasMfa =
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
+1
View File
@@ -91,6 +91,7 @@ export async function createAuthSession(
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
user = await AuthUtility.handleBanStatus(ctx, user);
const now = new Date();
const token = await AuthUtility.generateAuthToken(ctx);
let clientCountry: string | null = null;
@@ -225,7 +225,7 @@ function resolveEmailVerified({
if (values.includes(false)) {
return false;
}
return values.length === 0 || values.includes(true);
return values.length > 0 && values.includes(true);
}
function isJsonWebKeySet(value: unknown): value is JSONWebKeySet {
@@ -233,11 +233,14 @@ export class AttachmentUploadService {
const parts = await Promise.all(
Array.from({length: partCount}, async (_, index) => {
const partNumber = index + 1;
const partContentLength =
partNumber < partCount ? partSize : attachment.file_size - partSize * (partCount - 1);
const presigned_upload_url = await this.storageService.getPresignedUploadPartURL({
bucket,
key: uploadKey,
uploadId,
partNumber,
contentLength: partContentLength,
});
const upload_url = applyUploadRelayDecision({
presignedUrl: presigned_upload_url,
@@ -246,7 +249,7 @@ export class AttachmentUploadService {
relayDecision: uploadRelayDecision,
uploadId,
partNumber,
maxBytes: partSize,
maxBytes: partContentLength,
});
return {part_number: partNumber, upload_url};
}),
@@ -275,7 +278,7 @@ export class AttachmentUploadService {
if (!Config.presignedAttachmentUploadsEnabled) {
throw new FeatureTemporarilyDisabledError();
}
await this.getUploadPermissionAndLimit({userId, channelId});
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId});
const bucket = Config.s3.buckets.uploads;
return Promise.all(
uploads.map(async ({upload_filename, upload_id}, index) => {
@@ -305,6 +308,13 @@ export class AttachmentUploadService {
.catch(() => undefined);
throw InputValidationError.fromCode('parts', ValidationErrorCodes.NO_UPLOADED_PARTS_TO_FINALIZE);
}
const totalUploadedBytes = parts.reduce((sum, part) => sum + (part.size ?? 0), 0);
if (totalUploadedBytes > maxFileSize) {
await this.storageService
.abortMultipartUpload({bucket, key: upload_filename, uploadId: upload_id})
.catch(() => undefined);
throw new FileSizeTooLargeError(maxFileSize);
}
try {
await runAttachmentStorageOperation(() =>
this.storageService.completeMultipartUpload({
@@ -13,6 +13,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
import type {ChannelID, GuildID, UserID} from '../../BrandedTypes';
import {SYSTEM_USER_ID} from '../../constants/Core';
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from '../../guild/services/GuildMfaEnforcement';
import type {IGatewayService} from '../../infrastructure/IGatewayService';
import type {Channel} from '../../models/Channel';
import type {GuildMember} from '../../models/GuildMember';
@@ -173,8 +174,15 @@ export abstract class BaseChannelAuthService {
userId,
memberData: guildMemberResult.memberData!,
});
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData: guildDataResult!,
userId,
});
const hasPermission = async (permission: bigint): Promise<boolean> => {
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
const allowed = await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
if (allowed) enforceGuildMfa(permission);
return allowed;
};
const checkPermission = async (permission: bigint): Promise<void> => {
const allowed = await hasPermission(permission);
@@ -40,9 +40,8 @@ export class DMPermissionValidator {
if (isBugHunterBotUser(senderUser)) {
return;
}
if (!senderUser.isBot && (senderUser.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER) {
return;
}
const isShadowbannedSpammer =
!senderUser.isBot && (senderUser.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER;
const [senderBlockedTarget, targetBlockedSender, areFriends, targetSettings, senderSettings] = await Promise.all([
this.deps.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.BLOCKED),
this.deps.userRepository.getRelationship(recipientId, senderId, RelationshipTypes.BLOCKED),
@@ -53,6 +52,9 @@ export class DMPermissionValidator {
if (senderBlockedTarget || targetBlockedSender) {
throw new CannotSendMessagesToUserError();
}
if (isShadowbannedSpammer) {
return;
}
if (areFriends) {
return;
}
@@ -17,6 +17,7 @@ import type {IUserRepository} from '../../user/IUserRepository';
import {assertGuildMemberCanCommunicate} from '../../utils/GuildCommunicationUtils';
import type {IChannelRepository} from '../IChannelRepository';
import {MessageInteractionAuthService} from './interaction/MessageInteractionAuthService';
import {MessagePinAuthService} from './interaction/MessagePinAuthService';
import {MessagePinService} from './interaction/MessagePinService';
import {MessageReactionService} from './interaction/MessageReactionService';
import {MessageReadStateService} from './interaction/MessageReadStateService';
@@ -25,6 +26,7 @@ import type {MessagePersistenceService} from './message/MessagePersistenceServic
export class MessageInteractionService {
readonly authService: MessageInteractionAuthService;
private pinAuthService: MessagePinAuthService;
private readStateService: MessageReadStateService;
private pinService: MessagePinService;
private reactionService: MessageReactionService;
@@ -45,6 +47,12 @@ export class MessageInteractionService {
guildRepository,
gatewayService,
);
this.pinAuthService = new MessagePinAuthService(
channelRepository,
userRepository,
guildRepository,
gatewayService,
);
this.readStateService = new MessageReadStateService(gatewayService);
this.pinService = new MessagePinService(
gatewayService,
@@ -85,7 +93,7 @@ export class MessageInteractionService {
items: Array<ChannelPinResponse>;
has_more: boolean;
}> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
const authChannel = await this.pinAuthService.getChannelAuthenticated({userId, channelId});
return this.pinService.getChannelPins({authChannel, userId, requestCache, beforeTimestamp, limit});
}
@@ -186,12 +186,7 @@ export class ChannelOperationsService {
let permissionOverwrites = channel.permissionOverwrites;
if (data.permission_overwrites !== undefined) {
const guildId = createGuildID(BigInt(guild.id));
const canManageRoles = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
await checkPermission(Permissions.MANAGE_ROLES);
const isOwner = guild.owner_id === userId.toString();
const channelPermissions = await this.gatewayService.getUserPermissions({
guildId,
@@ -205,6 +200,17 @@ export class ChannelOperationsService {
throw new MissingPermissionsError();
}
}
const nextDeny = new Map<RoleID | UserID, bigint>();
for (const overwrite of data.permission_overwrites ?? []) {
const targetKey = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
nextDeny.set(targetKey, (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS);
}
for (const [targetId, existing] of previousPermissionOverwrites ?? []) {
const removedDeny = existing.deny & ~(nextDeny.get(targetId) ?? 0n);
if ((removedDeny & ~channelPermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
}
permissionOverwrites = new Map();
for (const overwrite of data.permission_overwrites ?? []) {
@@ -590,6 +596,7 @@ export class ChannelOperationsService {
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
@@ -615,6 +622,8 @@ export class ChannelOperationsService {
const sanitizedDeny = protectedBits.deny;
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (sanitizedAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny;
if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const previousPermissionOverwrites = channel.permissionOverwrites;
const overwrites = new Map(channel.permissionOverwrites ?? []);
overwrites.set(
@@ -697,6 +706,7 @@ export class ChannelOperationsService {
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
@@ -705,6 +715,15 @@ export class ChannelOperationsService {
const removedRole = overwrites.get(createRoleID(params.overwriteId));
const removedUser = overwrites.get(createUserID(params.overwriteId));
const removed = removedRole ?? removedUser;
if (removed) {
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
});
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
overwrites.delete(createRoleID(params.overwriteId));
overwrites.delete(createUserID(params.overwriteId));
const updated = await this.channelRepository.channelData.upsert({
@@ -2,7 +2,6 @@
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
import type {MessageID, UserID} from '../../../BrandedTypes';
import {Config} from '../../../Config';
import type {IPurgeQueue} from '../../../infrastructure/BunnyPurgeQueue';
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {IStorageService} from '../../../infrastructure/IStorageService';
@@ -13,7 +12,7 @@ import type {Message} from '../../../models/Message';
import {mapChannelToResponse} from '../../ChannelMappers';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import {dispatchMessageCreateBroadcast} from '../message/MessageGatewayDispatch';
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '../message/MessageHelpers';
import {purgeMessageAttachments} from '../message/MessageHelpers';
export class ChannelUtilsService {
constructor(
@@ -44,20 +43,7 @@ export class ChannelUtilsService {
}
private async purgeMessageAttachments(message: Message): Promise<void> {
const cdnUrls: Array<string> = [];
await Promise.all(
message.attachments.map(async (attachment) => {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
if (Config.bunny.purgeEnabled) {
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
cdnUrls.push(cdnUrl);
}
}),
);
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
await this.purgeQueue.addUrls(cdnUrls);
}
await purgeMessageAttachments(message, this.storageService, this.purgeQueue);
}
async dispatchChannelUpdate({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise<void> {
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {BaseChannelAuthService, type ChannelAuthOptions} from '../BaseChannelAuthService';
export class MessagePinAuthService extends BaseChannelAuthService {
protected readonly options: ChannelAuthOptions = {
errorOnMissingGuild: 'unknown_channel',
validateNsfw: true,
};
}
@@ -116,6 +116,16 @@ export class AttachmentProcessingService {
);
const hasVirusDetected = results.some((result) => result.hasVirusDetected);
if (hasVirusDetected) {
await Promise.all(
results.map(async (result) => {
if (result.sourceLocalPath) {
await fs.promises.unlink(result.sourceLocalPath).catch(() => undefined);
}
}),
);
for (const result of results) {
this.deleteUploadObject(result.copyOperation.sourceBucket, result.copyOperation.sourceKey);
}
return {attachments: [], hasVirusDetected: true};
}
const copyResults = await mapWithConcurrency(results, ATTACHMENT_PROCESSING_CONCURRENCY, (result) =>
@@ -225,14 +225,14 @@ export class MessageDeleteService {
async deleteUserMessagesInGuild({
userId,
guildId,
days,
seconds,
}: {
userId: UserID;
guildId: GuildID;
days: number;
seconds: number;
}): Promise<void> {
const channels = await this.deps.channelRepository.channelData.listGuildChannels(guildId);
const cutoffTimestamp = Date.now() - days * ms('1 day');
const cutoffTimestamp = Date.now() - seconds * ms('1 second');
const cutoffSnowflake = createMessageID(createSnowflakeFromTimestamp(cutoffTimestamp));
await Promise.all(
channels.map(async (channel: Channel) => {
@@ -25,6 +25,7 @@ import type {LimitConfigService} from '../../../limits/LimitConfigService';
import {resolveLimitSafe} from '../../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder';
import {Attachment} from '../../../models/Attachment';
import type {Embed} from '../../../models/Embed';
import type {Message} from '../../../models/Message';
import {MessageSnapshot as MessageSnapshotModel} from '../../../models/MessageSnapshot';
import type {User} from '../../../models/User';
@@ -307,22 +308,60 @@ export async function createMessageSnapshotsForForward(
return [new MessageSnapshotModel(snapshotData)];
}
function collectEmbedReferencedAttachmentCdnKeys(message: Message): Array<string> {
const mediaPrefix = `${Config.endpoints.media}/`;
const keys = new Set<string>();
const consider = (url: string | null | undefined): void => {
if (!url || !url.startsWith(mediaPrefix)) {
return;
}
const key = url.slice(mediaPrefix.length);
if (key.startsWith('attachments/')) {
keys.add(key);
}
};
const scanEmbeds = (embeds: Array<Embed>): void => {
for (const embed of embeds) {
consider(embed.image?.url);
consider(embed.thumbnail?.url);
consider(embed.video?.url);
consider(embed.audio?.url);
}
};
scanEmbeds(message.embeds);
for (const snapshot of message.messageSnapshots) {
scanEmbeds(snapshot.embeds);
}
return [...keys];
}
export async function purgeMessageAttachments(
message: Message,
storageService: IStorageService,
purgeQueue: IPurgeQueue,
): Promise<void> {
const cdnKeys = new Set<string>();
const cdnUrls: Array<string> = [];
await Promise.all(
message.attachments.map(async (attachment) => {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
await storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
if (Config.bunny.purgeEnabled) {
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
cdnUrls.push(cdnUrl);
}
}),
);
for (const attachment of collectMessageAttachments(message)) {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
if (cdnKeys.has(cdnKey)) {
continue;
}
cdnKeys.add(cdnKey);
if (Config.bunny.purgeEnabled) {
cdnUrls.push(makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename));
}
}
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message)) {
if (cdnKeys.has(embedKey)) {
continue;
}
cdnKeys.add(embedKey);
if (Config.bunny.purgeEnabled) {
cdnUrls.push(`${Config.endpoints.media}/${embedKey}`);
}
}
await Promise.all([...cdnKeys].map((cdnKey) => storageService.deleteObject(Config.s3.buckets.cdn, cdnKey)));
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
await purgeQueue.addUrls(cdnUrls);
}
@@ -14,6 +14,7 @@ import type {IFavoriteMemeRepository} from '../../../favorite_meme/IFavoriteMeme
import type {IMediaService} from '../../../infrastructure/IMediaService';
import type {ISnowflakeService} from '../../../infrastructure/ISnowflakeService';
import type {IStorageService} from '../../../infrastructure/IStorageService';
import {Logger} from '../../../Logger';
import type {FavoriteMeme} from '../../../models/FavoriteMeme';
import type {Message} from '../../../models/Message';
import type {User} from '../../../models/User';
@@ -90,8 +91,21 @@ export class MessageOperationsHelpers {
}
throw error;
}
const needsAnimationProbe =
!favoriteMeme.isGifv &&
favoriteMeme.contentType !== 'image/gif' &&
favoriteMeme.contentType !== 'image/apng' &&
ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType);
const metadata =
needsAnimationProbe || favoriteMeme.placeholder == null
? await this.probeFavoriteMemeMetadata(favoriteMeme)
: null;
const placeholder = favoriteMeme.placeholder ?? metadata?.placeholder ?? null;
if (placeholder != null && favoriteMeme.placeholder == null) {
await this.repairFavoriteMemePlaceholder(favoriteMeme, placeholder);
}
let flags = 0;
if (await this.isFavoriteMemeAnimated(favoriteMeme)) {
if (this.isFavoriteMemeAnimated(favoriteMeme, metadata)) {
flags |= MessageAttachmentFlags.IS_ANIMATED;
}
return {
@@ -104,7 +118,7 @@ export class MessageOperationsHelpers {
height: favoriteMeme.height,
content_type: favoriteMeme.contentType,
content_hash: favoriteMeme.contentHash,
placeholder: null,
placeholder,
flags,
duration: favoriteMeme.duration,
nsfw: null,
@@ -112,20 +126,31 @@ export class MessageOperationsHelpers {
};
}
private async isFavoriteMemeAnimated(favoriteMeme: FavoriteMeme): Promise<boolean> {
if (favoriteMeme.isGifv) return true;
if (favoriteMeme.contentType === 'image/gif' || favoriteMeme.contentType === 'image/apng') return true;
if (!ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType)) return false;
private async probeFavoriteMemeMetadata(favoriteMeme: FavoriteMeme) {
try {
const metadata = await this.deps.mediaService.getMetadata({
return await this.deps.mediaService.getMetadata({
type: 's3',
bucket: Config.s3.buckets.cdn,
key: favoriteMeme.storageKey,
nsfw: 'allow',
});
return metadata?.animated === true;
} catch {
return false;
return null;
}
}
private async repairFavoriteMemePlaceholder(favoriteMeme: FavoriteMeme, placeholder: string): Promise<void> {
try {
await this.deps.favoriteMemeRepository.updatePlaceholder(favoriteMeme.userId, favoriteMeme.id, placeholder);
} catch (error) {
Logger.warn({error, memeId: favoriteMeme.id.toString()}, 'Failed to backfill favorite meme placeholder');
}
}
private isFavoriteMemeAnimated(favoriteMeme: FavoriteMeme, metadata: {animated?: boolean | null} | null): boolean {
if (favoriteMeme.isGifv) return true;
if (favoriteMeme.contentType === 'image/gif' || favoriteMeme.contentType === 'image/apng') return true;
if (!ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType)) return false;
return metadata?.animated === true;
}
}
@@ -248,9 +248,10 @@ export class MessageValidationService {
}
const isAuthor = message.authorId === userId;
if (!guild) return isAuthor;
if (isAuthor) return true;
const canManageMessages =
(await hasPermission(Permissions.SEND_MESSAGES)) && (await hasPermission(Permissions.MANAGE_MESSAGES));
return isAuthor || canManageMessages;
return canManageMessages;
}
private validateVoiceMessageConstraints(
@@ -30,6 +30,7 @@ import {Config} from '../Config';
import type {IChannelRepository} from '../channel/IChannelRepository';
import type {AttachmentUploadTraceRepository} from '../channel/repositories/message/AttachmentUploadTraceRepository';
import {
collectMessageAttachments,
makeAttachmentCdnKey,
makeAttachmentCdnUrl,
purgeMessageAttachments,
@@ -43,6 +44,9 @@ import type {IStorageService} from '../infrastructure/IStorageService';
import type {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDeletionQueueService';
import type {UserCacheService} from '../infrastructure/UserCacheService';
import {Logger} from '../Logger';
import type {Embed} from '../models/Embed';
import type {EmbedMedia} from '../models/EmbedMedia';
import type {Message} from '../models/Message';
import type {User} from '../models/User';
import type {IARMessageContext, IARSubmission} from '../report/IReportRepository';
import type {ReportRepository} from '../report/ReportRepository';
@@ -471,18 +475,34 @@ export class NcmecSubmissionService {
private async resolveAttachment(input: SubmitAttachmentToNcmecInput): Promise<ResolvedAttachment> {
const liveMessage = await this.deps.channelRepository.getMessage(input.channelId, input.messageId);
const liveAttachment = liveMessage?.attachments.find(
(attachment) => attachment.id === input.attachmentId && attachment.filename === input.filename,
);
if (liveMessage?.authorId && liveAttachment) {
return this.buildAttachmentContext(input, {
contentType: liveAttachment.contentType,
reportedAt: snowflakeToDate(input.messageId),
userId: liveMessage.authorId,
sourceReportId: input.sourceReportId ?? null,
bucket: Config.s3.buckets.cdn,
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename),
});
if (liveMessage?.authorId) {
const liveAttachment = collectMessageAttachments(liveMessage).find(
(attachment) => attachment.id === input.attachmentId && attachment.filename === input.filename,
);
if (liveAttachment) {
return this.buildAttachmentContext(input, {
contentType: liveAttachment.contentType,
reportedAt: snowflakeToDate(input.messageId),
userId: liveMessage.authorId,
sourceReportId: input.sourceReportId ?? null,
bucket: Config.s3.buckets.cdn,
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename),
});
}
const embedMedia = findEmbedReferencedAttachmentMedia(
liveMessage,
makeAttachmentCdnUrl(input.channelId, input.attachmentId, input.filename),
);
if (embedMedia) {
return this.buildAttachmentContext(input, {
contentType: embedMedia.contentType,
reportedAt: snowflakeToDate(input.messageId),
userId: liveMessage.authorId,
sourceReportId: input.sourceReportId ?? null,
bucket: Config.s3.buckets.cdn,
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename),
});
}
}
if (!input.sourceReportId) {
throw new UnknownMessageError();
@@ -939,6 +959,30 @@ function buildAuditMetadata(args: {
return metadata;
}
function findEmbedReferencedAttachmentMedia(message: Message, targetCdnUrl: string): EmbedMedia | null {
const scan = (embeds: Array<Embed>): EmbedMedia | null => {
for (const embed of embeds) {
for (const media of [embed.image, embed.thumbnail, embed.video, embed.audio]) {
if (media?.url === targetCdnUrl) {
return media;
}
}
}
return null;
};
const direct = scan(message.embeds);
if (direct) {
return direct;
}
for (const snapshot of message.messageSnapshots) {
const found = scan(snapshot.embeds);
if (found) {
return found;
}
}
return null;
}
function findAttachmentInReport(
report: IARSubmission,
channelId: ChannelID,
@@ -17,6 +17,7 @@ export interface GiftCodeRow {
stripe_payment_intent_id: Nullish<string>;
visionary_sequence_number: Nullish<number>;
checkout_session_id: Nullish<string>;
revoked_at?: Nullish<Date>;
version: number;
}
@@ -103,6 +104,7 @@ export const GIFT_CODE_COLUMNS = [
'stripe_payment_intent_id',
'visionary_sequence_number',
'checkout_session_id',
'revoked_at',
'version',
] as const;
export const GIFT_CODE_BY_CREATOR_COLUMNS = ['created_by_user_id', 'code'] as const;
@@ -51,6 +51,7 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
}
const DESKTOP_BUCKET_PREFIX = 'desktop';
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
const DEFAULT_API_CLIENT_BASE_URL = 'https://api.fluxer.app';
const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/fluxerapp/fluxer/releases/download';
const GITHUB_RELEASE_MARKER_DIRECTORY = 'github-releases';
@@ -987,7 +988,13 @@ export class DownloadService {
return null;
}
}
return normalized.length > 0 ? normalized : null;
if (normalized.length === 0) {
return null;
}
if (!DOWNLOAD_KEY_ALLOWED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return null;
}
return normalized;
}
private normalizePlatformArchKey(key: string): string | null {
@@ -2,6 +2,7 @@
import type {MemeID, UserID} from '../BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
import {Db} from '../database/CassandraTypes';
import type {FavoriteMemeRow} from '../database/types/UserTypes';
import {FavoriteMeme} from '../models/FavoriteMeme';
import {FavoriteMemes, FavoriteMemesByMemeId} from '../Tables';
@@ -99,6 +100,10 @@ export class FavoriteMemeRepository extends IFavoriteMemeRepository {
return new FavoriteMeme(memeRow);
}
async updatePlaceholder(userId: UserID, memeId: MemeID, placeholder: string): Promise<void> {
await fetchOne(FavoriteMemes.patchByPk({user_id: userId, meme_id: memeId}, {placeholder: Db.set(placeholder)}));
}
async delete(userId: UserID, memeId: MemeID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(FavoriteMemes.deleteByPk({user_id: userId, meme_id: memeId}));
@@ -34,6 +34,8 @@ export abstract class IFavoriteMemeRepository {
abstract update(userId: UserID, memeId: MemeID, data: CreateFavoriteMemeParams): Promise<FavoriteMeme>;
abstract updatePlaceholder(userId: UserID, memeId: MemeID, placeholder: string): Promise<void>;
abstract delete(userId: UserID, memeId: MemeID): Promise<void>;
abstract deleteAllByUserId(userId: UserID): Promise<void>;
@@ -262,7 +262,7 @@ export function GuildMemberController(app: HonoApp) {
const userId = ctx.get('user').id;
const targetId = createUserID(user_id);
const guildId = createGuildID(guild_id);
const {delete_message_days, reason, ban_duration_seconds} = ctx.req.valid('json');
const {delete_message_days, delete_message_seconds, reason, ban_duration_seconds} = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const effectiveReason = reason ?? auditLogReason ?? undefined;
await ctx.get('guildService').moderation.banMember(
@@ -271,6 +271,7 @@ export function GuildMemberController(app: HonoApp) {
guildId,
targetId,
deleteMessageDays: delete_message_days,
deleteMessageSeconds: delete_message_seconds,
reason: effectiveReason,
banDurationSeconds: ban_duration_seconds,
},
@@ -14,9 +14,11 @@ import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
import type {UserCacheService} from '../../infrastructure/UserCacheService';
import type {LimitConfigService} from '../../limits/LimitConfigService';
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {IUserRepository} from '../../user/IUserRepository';
import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
import {ChannelOperationsService} from './channel/ChannelOperationsService';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
export class GuildChannelService {
private readonly channelOps: ChannelOperationsService;
@@ -30,6 +32,7 @@ export class GuildChannelService {
snowflakeService: ISnowflakeService,
guildAuditLogService: GuildAuditLogService,
limitConfigService: LimitConfigService,
private readonly userRepository: IUserRepository,
) {
this.channelOps = new ChannelOperationsService(
channelRepository,
@@ -131,5 +134,12 @@ export class GuildChannelService {
permission: params.permission,
});
if (!hasPermission) throw new MissingPermissionsError();
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData,
userId: params.userId,
});
enforceGuildMfa(params.permission);
}
}
@@ -51,7 +51,7 @@ export class GuildDataService {
private readonly guildAuditLogService: GuildAuditLogService,
private readonly limitConfigService: LimitConfigService,
) {
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService);
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService, this.userRepository);
this.operationsService = new GuildOperationsService(
this.guildRepository,
this.channelRepository,
@@ -50,7 +50,7 @@ export class GuildMemberService {
ipInfoService: IpInfoService,
) {
this.userRepository = userRepository;
this.authService = new GuildMemberAuthService(gatewayService);
this.authService = new GuildMemberAuthService(gatewayService, userRepository);
this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService);
this.auditService = new GuildMemberAuditService(guildAuditLogService);
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {UserID} from '../../BrandedTypes';
import type {IUserRepository} from '../../user/IUserRepository';
const ELEVATED_MFA_PERMISSIONS =
Permissions.KICK_MEMBERS |
Permissions.BAN_MEMBERS |
Permissions.ADMINISTRATOR |
Permissions.MANAGE_CHANNELS |
Permissions.MANAGE_GUILD |
Permissions.MANAGE_MESSAGES |
Permissions.MANAGE_ROLES |
Permissions.MANAGE_WEBHOOKS |
Permissions.MODERATE_MEMBERS;
export async function createGuildMfaEnforcer(params: {
userRepository: IUserRepository;
guildData: Pick<GuildResponse, 'mfa_level' | 'owner_id'>;
userId: UserID;
}): Promise<(permission: bigint) => void> {
const {userRepository, guildData, userId} = params;
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
let actorLacksMfa = false;
if (requiresGuildMfa) {
const actor = await userRepository.findUnique(userId);
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
}
return (permission: bigint) => {
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
throw new MfaNotEnabledError();
}
};
}
@@ -27,8 +27,11 @@ import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
import {mapGuildBansToResponse} from '../GuildModel';
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
import {GuildMemberSearchIndexService} from './member/GuildMemberSearchIndexService';
const SECONDS_PER_DAY = 86_400;
export class GuildModerationService {
private readonly searchIndexService: GuildMemberSearchIndexService;
@@ -44,25 +47,43 @@ export class GuildModerationService {
this.searchIndexService = new GuildMemberSearchIndexService();
}
private async checkModerationPermission(params: {
guildId: GuildID;
userId: UserID;
permission: bigint;
}): Promise<void> {
const {guildId, userId, permission} = params;
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
const guildData = await this.gatewayService.getGuildData({guildId, userId});
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
enforceGuildMfa(permission);
}
async banMember(
params: {
userId: UserID;
targetId: UserID;
guildId: GuildID;
deleteMessageDays?: number;
deleteMessageSeconds?: number;
reason?: string | null;
banDurationSeconds?: number;
skipGuildAuditLog?: boolean;
},
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds, skipGuildAuditLog} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
const {
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
guildId,
targetId,
deleteMessageDays,
deleteMessageSeconds,
reason,
banDurationSeconds,
skipGuildAuditLog,
} = params;
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
if (userId === targetId) throw new UnknownGuildMemberError();
const targetUser = await this.userRepository.findUnique(targetId);
if (!targetUser) {
@@ -73,11 +94,13 @@ export class GuildModerationService {
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId: targetId});
if (!canManage) throw new MissingPermissionsError();
}
if (deleteMessageDays && deleteMessageDays > 0) {
const effectiveDeleteMessageSeconds =
deleteMessageSeconds ?? (deleteMessageDays !== undefined ? deleteMessageDays * SECONDS_PER_DAY : undefined);
if (effectiveDeleteMessageSeconds && effectiveDeleteMessageSeconds > 0) {
await this.workerService.addJob('deleteUserMessagesInGuildByTime', {
guildId: guildId.toString(),
userId: targetId.toString(),
days: deleteMessageDays,
seconds: effectiveDeleteMessageSeconds,
});
}
const targetIp = isIpBanExempt(targetUser.lastActiveIp) ? null : targetUser.lastActiveIp || null;
@@ -145,12 +168,7 @@ export class GuildModerationService {
requestCache: RequestCache;
}): Promise<Array<GuildBanResponse>> {
const {userId, guildId, requestCache} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
const bans = await this.guildRepository.listBans(guildId);
return await mapGuildBansToResponse(bans, this.userCacheService, requestCache);
}
@@ -164,12 +182,7 @@ export class GuildModerationService {
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
const ban = await this.guildRepository.getBan(guildId, targetId);
if (!ban) {
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
@@ -26,6 +26,7 @@ import type {LimitConfigService} from '../../limits/LimitConfigService';
import {resolveLimitSafe} from '../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder';
import {GuildRole} from '../../models/GuildRole';
import type {IUserRepository} from '../../user/IUserRepository';
import {applyProtectedRolePermissions} from '../../utils/featureUtils';
import {computePermissionsDiff} from '../../utils/PermissionUtils';
import type {GuildAuditLogService} from '../GuildAuditLogService';
@@ -33,6 +34,7 @@ import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
import {mapGuildRoleToResponse} from '../GuildModel';
import type {IGuildMemberRepository} from '../repositories/IGuildMemberRepository';
import type {IGuildRoleRepository} from '../repositories/IGuildRoleRepository';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
interface GuildRoleRepository extends IGuildRoleRepository, IGuildMemberRepository {}
@@ -62,6 +64,7 @@ export class GuildRoleService {
private readonly gatewayService: IGatewayService,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly limitConfigService: LimitConfigService,
private readonly userRepository: IUserRepository,
) {}
async systemCreateRole(params: {
@@ -305,7 +308,7 @@ export class GuildRoleService {
position?: number;
}>;
},
_auditLogReason?: string | null,
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, updates} = params;
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
@@ -316,7 +319,7 @@ export class GuildRoleService {
throw new ResourceLockedError();
}
try {
await this.updateRolePositionsByList({userId, guildId, updates});
await this.updateRolePositionsByList({userId, guildId, updates, auditLogReason: auditLogReason ?? null});
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
}
@@ -342,7 +345,7 @@ export class GuildRoleService {
hoistPosition: number;
}>;
},
_auditLogReason?: string | null,
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, updates} = params;
const {checkPermission, guildData} = await this.getGuildAuthenticated({userId, guildId});
@@ -400,6 +403,7 @@ export class GuildRoleService {
}
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
@@ -411,7 +415,7 @@ export class GuildRoleService {
userId: UserID;
guildId: GuildID;
},
_auditLogReason?: string | null,
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId} = params;
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
@@ -423,6 +427,7 @@ export class GuildRoleService {
}
try {
const allRoles = await this.guildRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
const changedRoles: Array<GuildRole> = [];
for (const role of allRoles) {
if (role.hoistPosition === null) continue;
@@ -438,6 +443,7 @@ export class GuildRoleService {
}
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
@@ -446,9 +452,11 @@ export class GuildRoleService {
private async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
return {
@@ -554,8 +562,9 @@ export class GuildRoleService {
roleId: RoleID;
position?: number;
}>;
auditLogReason?: string | null;
}): Promise<void> {
const {userId, guildId, updates} = params;
const {userId, guildId, updates, auditLogReason} = params;
const {guildData} = await this.getGuildAuthenticated({userId, guildId});
const allRoles = await this.guildRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
@@ -620,6 +629,7 @@ export class GuildRoleService {
});
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
}
}
@@ -694,6 +704,30 @@ export class GuildRoleService {
return newRoles;
}
private async recordRolePositionAuditLogs(params: {
guildId: GuildID;
userId: UserID;
roleMap: Map<RoleID, GuildRole>;
changedRoles: Array<GuildRole>;
auditLogReason?: string | null;
}): Promise<void> {
const {guildId, userId, roleMap, changedRoles, auditLogReason} = params;
for (const role of changedRoles) {
const oldRole = roleMap.get(role.id);
await this.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.ROLE_UPDATE,
targetId: role.id,
auditLogReason: auditLogReason ?? null,
changes: this.guildAuditLogService.computeChanges(
oldRole ? this.serializeRoleForAudit(oldRole) : null,
this.serializeRoleForAudit(role),
),
});
}
}
private serializeRoleForAudit(role: GuildRole): Record<string, unknown> {
return {
role_id: role.id.toString(),
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
@@ -22,6 +22,7 @@ import {buildMessageSearchFilters} from '../../search/BuildMessageSearchFilters'
import {channelNeedsReindexing} from '../../search/ChannelIndexingUtils';
import {MessageSearchResponseMapper} from '../../search/MessageSearchResponseMapper';
import {searchExistingMessages} from '../../search/MessageSearchResultReconciler';
import {channelRequiresAgeVerification} from '../../search/SearchNsfwUtils';
import type {IUserRepository} from '../../user/IUserRepository';
import {canUserAccessNsfwContent} from '../../utils/AgeUtils';
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
@@ -84,6 +85,7 @@ export class GuildSearchService {
}
}
const canIncludeNsfw = includeNsfwRequested && canUserAccessNsfw;
const guildNsfw = guildData?.nsfw ?? false;
const channels = await this.channelRepository.listChannels(channelIds);
const channelMap = new Map<string, Channel>();
for (const channel of channels) {
@@ -96,9 +98,10 @@ export class GuildSearchService {
throw InputValidationError.fromCode('channel_ids', ValidationErrorCodes.ALL_CHANNELS_MUST_BELONG_TO_GUILD);
}
}
const categoryLookup = await this.buildParentCategoryLookup(channelMap);
const nsfwFilteredIds = channelIds.filter((id) => {
const channel = channelMap.get(id.toString())!;
return !(channel.isNsfw && !canIncludeNsfw);
return !(channelRequiresAgeVerification(channel, categoryLookup, guildNsfw) && !canIncludeNsfw);
});
const permissionResults = await mapWithConcurrency(nsfwFilteredIds, PERMISSION_CHECK_CONCURRENCY, (channelId) =>
this.gatewayService.checkPermission({
@@ -188,7 +191,7 @@ export class GuildSearchService {
if (!searchService) {
throw new FeatureTemporarilyDisabledError();
}
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} =
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} =
await this.collectAccessibleGuildChannels(userId);
if (unindexedChannelIds.size > 0) {
await this.queueIndexingChannels(unindexedChannelIds);
@@ -219,7 +222,7 @@ export class GuildSearchService {
if (guildIsAgeRestricted) {
return canIncludeNsfw;
}
if (channel.isNsfw) {
if (channelRequiresAgeVerification(channel, parentCategories, false)) {
return canIncludeNsfw;
}
return true;
@@ -259,6 +262,24 @@ export class GuildSearchService {
};
}
private async buildParentCategoryLookup(channelMap: Map<string, Channel>): Promise<Map<string, Channel>> {
const lookup = new Map<string, Channel>(channelMap);
const missingParentIds: Array<ChannelID> = [];
for (const channel of channelMap.values()) {
const parentId = channel.parentId;
if (parentId != null && !lookup.has(parentId.toString())) {
missingParentIds.push(parentId);
}
}
if (missingParentIds.length > 0) {
const parents = await this.channelRepository.listChannels(missingParentIds);
for (const parent of parents) {
lookup.set(parent.id.toString(), parent);
}
}
return lookup;
}
private async getCanUserAccessNsfw(userId: UserID): Promise<boolean> {
const user = await this.userRepository.findUnique(userId);
if (!user) {
@@ -284,11 +305,13 @@ export class GuildSearchService {
accessibleChannels: Map<string, Channel>;
unindexedChannelIds: Set<string>;
guildNsfwLevels: Map<string, number>;
parentCategories: Map<string, Channel>;
}> {
const guildIds = await this.userRepository.getUserGuildIds(userId);
const accessibleChannels = new Map<string, Channel>();
const unindexedChannelIds = new Set<string>();
const guildNsfwLevels = new Map<string, number>();
const parentCategories = new Map<string, Channel>();
const permissionChecks: Array<{
channel: Channel;
guildId: GuildID;
@@ -304,6 +327,9 @@ export class GuildSearchService {
}
const viewableChannelIds = new Set(viewableChannels.map((channelId) => channelId.toString()));
for (const channel of guildChannels) {
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
parentCategories.set(channel.id.toString(), channel);
}
if (viewableChannelIds.has(channel.id.toString())) {
permissionChecks.push({channel, guildId});
}
@@ -331,6 +357,6 @@ export class GuildSearchService {
unindexedChannelIds.add(channelIdStr);
}
}
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels};
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories};
}
}
@@ -2,9 +2,8 @@
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildFeatures, GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
@@ -44,6 +43,7 @@ import {GuildChannelService} from './GuildChannelService';
import {GuildContentService} from './GuildContentService';
import {GuildDataService} from './GuildDataService';
import {GuildMemberService} from './GuildMemberService';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
import {GuildModerationService} from './GuildModerationService';
import {GuildRoleService} from './GuildRoleService';
import {GuildSearchService} from './GuildSearchService';
@@ -95,17 +95,6 @@ interface GuildAuth {
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}
const ELEVATED_MFA_PERMISSIONS =
Permissions.KICK_MEMBERS |
Permissions.BAN_MEMBERS |
Permissions.ADMINISTRATOR |
Permissions.MANAGE_CHANNELS |
Permissions.MANAGE_GUILD |
Permissions.MANAGE_MESSAGES |
Permissions.MANAGE_ROLES |
Permissions.MANAGE_WEBHOOKS |
Permissions.MODERATE_MEMBERS;
export class GuildService {
public readonly data: GuildDataService;
public readonly members: GuildMemberService;
@@ -182,6 +171,7 @@ export class GuildService {
gatewayService,
guildAuditLogService,
limitConfigService,
userRepository,
);
this.moderation = new GuildModerationService(
guildRepository,
@@ -211,6 +201,7 @@ export class GuildService {
snowflakeService,
guildAuditLogService,
limitConfigService,
userRepository,
);
this.search = new GuildSearchService(
channelRepository,
@@ -561,17 +552,7 @@ export class GuildService {
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new MissingAccessError();
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
let actorLacksMfa = false;
if (requiresGuildMfa) {
const actor = await this.userRepository.findUnique(userId);
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
}
const enforceGuildMfa = (permission: bigint) => {
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
throw new MfaNotEnabledError();
}
};
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
@@ -355,12 +355,18 @@ export class ChannelOperationsService {
requestCache,
});
if (update.lockPermissions && desiredParent && desiredParent !== (target.parentId ?? null)) {
await this.syncPermissionsWithParent({guildId, channelId: target.id, parentId: desiredParent});
await this.syncPermissionsWithParent({
guildId,
userId: params.userId,
channelId: target.id,
parentId: desiredParent,
});
}
}
private async syncPermissionsWithParent(params: {
guildId: GuildID;
userId: UserID;
channelId: ChannelID;
parentId: ChannelID;
}): Promise<void> {
@@ -368,6 +374,22 @@ export class ChannelOperationsService {
if (!parent || parent.guildId !== params.guildId || parent.type !== ChannelTypes.GUILD_CATEGORY) return;
const child = await this.channelRepository.findUnique(params.channelId);
if (!child || child.guildId !== params.guildId) return;
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: params.guildId,
userId: params.userId,
channelId: child.id,
});
if ((userPermissions & Permissions.MANAGE_ROLES) === 0n) {
throw new MissingPermissionsError();
}
for (const [targetId, existing] of child.permissionOverwrites) {
const incomingDeny = parent.permissionOverwrites.get(targetId)?.deny ?? 0n;
if ((existing.deny & ~incomingDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
for (const [targetId, incoming] of parent.permissionOverwrites) {
const existingAllow = child.permissionOverwrites.get(targetId)?.allow ?? 0n;
if ((incoming.allow & ~existingAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
await this.channelRepository.upsert({
...child.toRow(),
permission_overwrites: new Map(
@@ -8,12 +8,14 @@ import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '../..
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import {Logger} from '../../../Logger';
import type {Guild} from '../../../models/Guild';
import type {IUserRepository} from '../../../user/IUserRepository';
import {serializeGuildForAudit as serializeGuildForAuditUtil} from '../../../utils/AuditSerializationUtils';
import {requirePermission} from '../../../utils/PermissionUtils';
import type {GuildAuditLogService} from '../../GuildAuditLogService';
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
import {mapGuildToGuildResponse} from '../../GuildModel';
import {GuildRepository} from '../../repositories/GuildRepository';
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
interface GuildAuth {
guildData: GuildResponse;
@@ -24,6 +26,7 @@ export class GuildDataHelpers {
constructor(
private readonly gatewayService: IGatewayService,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly userRepository: IUserRepository,
) {}
private readonly guildRepository = new GuildRepository();
@@ -33,7 +36,7 @@ export class GuildDataHelpers {
try {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new UnknownGuildError();
return this.createGuildAuth({guildData, guildId, userId});
return await this.createGuildAuth({guildData, guildId, userId});
} catch (error) {
if (error instanceof UnknownGuildError && (await this.guildExists(guildId))) {
throw new AccessDeniedError();
@@ -42,10 +45,16 @@ export class GuildDataHelpers {
}
}
private createGuildAuth(params: {guildData: GuildResponse; guildId: GuildID; userId: UserID}): GuildAuth {
private async createGuildAuth(params: {
guildData: GuildResponse;
guildId: GuildID;
userId: UserID;
}): Promise<GuildAuth> {
const {guildData, guildId, userId} = params;
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
await requirePermission(this.gatewayService, {guildId, userId, permission});
enforceGuildMfa(permission);
};
return {guildData, checkPermission};
}
@@ -57,7 +57,7 @@ import {Guild} from '../../../models/Guild';
import type {User} from '../../../models/User';
import {getGuildSearchService} from '../../../SearchFactory';
import type {GuildDiscoveryContext} from '../../../search/guild/GuildSearchSerializer';
import {deleteGuildMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
import {deleteChannelMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
import {Channels, ChannelsByGuild, GuildMembers, GuildMembersByUserId, GuildRoles, Guilds} from '../../../Tables';
import type {IUserRepository} from '../../../user/IUserRepository';
import {mapUserSettingsToResponse} from '../../../user/UserMappers';
@@ -858,7 +858,11 @@ export class GuildOperationsService {
await Promise.all(webhooks.map((webhook) => this.webhookRepository.delete(webhook.id)));
const channels = await this.channelRepository.listGuildChannels(guildId);
await Promise.all(channels.map((channel) => this.channelRepository.deleteAllChannelMessages(channel.id)));
await deleteGuildMessageSearchDocuments(guildId, {context: {source: 'guild_delete'}});
await Promise.all(
channels.map((channel) =>
deleteChannelMessageSearchDocuments(channel.id, {context: {source: 'guild_delete'}}),
),
);
await Promise.all(channels.map((channel) => this.channelService.attachments.purgeChannelAttachments(channel)));
const discoveryRow = await this.discoveryRepository.findByGuildId(guildId);
if (discoveryRow) {
@@ -5,6 +5,8 @@ import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPe
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {IUserRepository} from '../../../user/IUserRepository';
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
interface GuildAuth {
guildData: GuildResponse;
@@ -16,22 +18,30 @@ interface GuildAuth {
}
export class GuildMemberAuthService {
constructor(private readonly gatewayService: IGatewayService) {}
constructor(
private readonly gatewayService: IGatewayService,
private readonly userRepository: IUserRepository,
) {}
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new MissingAccessError();
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
const checkTargetMember = async (targetUserId: UserID) => {
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId});
if (!canManage) throw new MissingPermissionsError();
};
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
const hasPermission = async (permission: bigint) =>
this.gatewayService.checkPermission({guildId, userId, permission});
const hasPermission = async (permission: bigint) => {
const allowed = await this.gatewayService.checkPermission({guildId, userId, permission});
if (allowed) enforceGuildMfa(permission);
return allowed;
};
const canManageRoles = async (targetUserId: UserID, targetRoleId: RoleID) =>
this.gatewayService.canManageRoles({guildId, userId, targetUserId, roleId: targetRoleId});
return {
@@ -42,7 +42,7 @@ export class GuildMemberRoleService {
requestCache: RequestCache;
}): Promise<void> {
const {userId, targetId, guildId, roleId} = params;
const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
const {guildData, hasPermission, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
await this.validationService.validateRoleAssignment({
@@ -51,6 +51,7 @@ export class GuildMemberRoleService {
userId,
targetId,
roleId,
hasPermission,
canManageRoles,
});
if (targetMember.roleIds.has(roleId)) return;
@@ -75,7 +76,7 @@ export class GuildMemberRoleService {
requestCache: RequestCache;
}): Promise<void> {
const {userId, targetId, guildId, roleId} = params;
const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
const {guildData, hasPermission, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
await this.validationService.validateRoleAssignment({
@@ -84,6 +85,7 @@ export class GuildMemberRoleService {
userId,
targetId,
roleId,
hasPermission,
canManageRoles,
});
if (!targetMember.roleIds.has(roleId)) return;
@@ -74,9 +74,10 @@ export class GuildMemberValidationService {
userId: UserID;
targetId: UserID;
roleId: RoleID;
hasPermission: (permission: bigint) => Promise<boolean>;
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}): Promise<void> {
const {guildData, guildId, userId, targetId, roleId, canManageRoles} = params;
const {guildData, guildId, userId, targetId, roleId, hasPermission, canManageRoles} = params;
ensureNotEveryoneRole(roleId, guildId, 'role_id');
if (guildData && guildData.owner_id === userId.toString()) {
const role = await this.guildRepository.getRole(roleId, guildId);
@@ -84,6 +85,9 @@ export class GuildMemberValidationService {
throw new UnknownGuildRoleError();
}
} else {
if (!(await hasPermission(Permissions.MANAGE_ROLES))) {
throw new MissingPermissionsError();
}
if (!(await canManageRoles(targetId, roleId))) {
throw new MissingPermissionsError();
}
@@ -91,6 +91,7 @@ export interface IStorageService {
key: string;
uploadId: string;
partNumber: number;
contentLength?: number;
expiresIn?: number;
}): Promise<string>;
purgeBucket(bucket: string): Promise<void>;
@@ -296,12 +296,14 @@ export class StorageService implements IStorageService {
key,
uploadId,
partNumber,
contentLength,
expiresIn = seconds('1 hour'),
}: {
bucket: string;
key: string;
uploadId: string;
partNumber: number;
contentLength?: number;
expiresIn?: number;
}): Promise<string> {
const command = new UploadPartCommand({
@@ -309,6 +311,7 @@ export class StorageService implements IStorageService {
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
ContentLength: contentLength,
});
return getSignedUrl(this.presignClient, command, {expiresIn});
}
@@ -52,7 +52,11 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean {
function getClientIdentifier(ctx: Context<HonoEnv>): string {
const user = ctx.get('user');
if (user?.id) {
return `user:${user.id}`;
const tokenType = ctx.get('authTokenType') ?? 'session';
if (tokenType === 'bearer') {
return `user:${user.id}:bearer:${ctx.get('oauthBearerApplicationId') ?? 'unknown'}`;
}
return `user:${user.id}:${tokenType}`;
}
const ip = extractClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
+3
View File
@@ -113,6 +113,7 @@ export class GiftCode {
readonly stripePaymentIntentId: string | null;
readonly visionarySequenceNumber: number | null;
readonly checkoutSessionId: string | null;
readonly revokedAt: Date | null;
readonly version: number;
constructor(row: GiftCodeRow) {
@@ -128,6 +129,7 @@ export class GiftCode {
this.stripePaymentIntentId = row.stripe_payment_intent_id ?? null;
this.visionarySequenceNumber = row.visionary_sequence_number ?? null;
this.checkoutSessionId = row.checkout_session_id ?? null;
this.revokedAt = row.revoked_at ?? null;
this.version = row.version;
}
@@ -144,6 +146,7 @@ export class GiftCode {
stripe_payment_intent_id: this.stripePaymentIntentId,
visionary_sequence_number: this.visionarySequenceNumber,
checkout_session_id: this.checkoutSessionId,
revoked_at: this.revokedAt,
version: this.version,
};
}
+1 -1
View File
@@ -254,7 +254,7 @@ function normalizeStoredSyncedPreferences(raw: string | null | undefined): strin
try {
decodeSyncedPreferences(raw);
} catch {
return '';
return raw;
}
return raw;
}
@@ -316,7 +316,10 @@ export class OAuth2RequestService {
throw err;
}
}
Logger.info({redirectTo}, 'OAuth2 consent: returning redirect URL');
Logger.info(
{clientId: params.body.client_id.toString(), userId: params.userId.toString()},
'OAuth2 consent: returning redirect URL',
);
return {redirect_to: redirectTo};
}
@@ -3,3 +3,4 @@
import {seconds} from 'itty-time';
export const ACCESS_TOKEN_TTL_SECONDS = seconds('7 days');
export const AUTHORIZATION_CODE_TTL_SECONDS = seconds('10 minutes');
@@ -19,13 +19,17 @@ import {
OAuth2RefreshTokens,
OAuth2RefreshTokensByUser,
} from '../../Tables';
import {ACCESS_TOKEN_TTL_SECONDS} from '../OAuth2TokenConstants';
import {ACCESS_TOKEN_TTL_SECONDS, AUTHORIZATION_CODE_TTL_SECONDS} from '../OAuth2TokenConstants';
import type {IOAuth2TokenRepository} from './IOAuth2TokenRepository';
function isAccessTokenExpired(createdAt: Date): boolean {
return Date.now() - createdAt.getTime() > ACCESS_TOKEN_TTL_SECONDS * 1000;
}
function isAuthorizationCodeExpired(createdAt: Date): boolean {
return Date.now() - createdAt.getTime() > AUTHORIZATION_CODE_TTL_SECONDS * 1000;
}
const SELECT_AUTHORIZATION_CODE = OAuth2AuthorizationCodes.selectCql({
where: OAuth2AuthorizationCodes.where.eq('code'),
});
@@ -46,13 +50,19 @@ const SELECT_REFRESH_TOKENS_BY_USER = OAuth2RefreshTokensByUser.selectCql({
export class OAuth2TokenRepository implements IOAuth2TokenRepository {
async createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode> {
await upsertOne(OAuth2AuthorizationCodes.insert(data));
await upsertOne(OAuth2AuthorizationCodes.insertWithTtl(data, AUTHORIZATION_CODE_TTL_SECONDS));
return new OAuth2AuthorizationCode(data);
}
async getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null> {
const row = await fetchOne<OAuth2AuthorizationCodeRow>(SELECT_AUTHORIZATION_CODE, {code});
return row ? new OAuth2AuthorizationCode(row) : null;
if (!row) {
return null;
}
if (isAuthorizationCodeExpired(row.created_at)) {
return null;
}
return new OAuth2AuthorizationCode(row);
}
async deleteAuthorizationCode(code: string): Promise<void> {
+8 -1
View File
@@ -30823,7 +30823,14 @@
"minimum": 0,
"maximum": 7,
"format": "int32",
"description": "Number of days of messages to delete from the banned user (0-7)"
"description": "Number of days of messages to delete from the banned user (0-7). Deprecated in favor of delete_message_seconds."
},
"delete_message_seconds": {
"type": "integer",
"minimum": 0,
"maximum": 604800,
"format": "int32",
"description": "Number of seconds of messages to delete for the banned user (0-604800, default 0)"
},
"reason": {
"anyOf": [{"type": "string"}, {"type": "null"}],
@@ -30,6 +30,7 @@ import {channelNeedsReindexing} from './ChannelIndexingUtils';
import type {IMessageSearchService} from './IMessageSearchService';
import {MessageSearchResponseMapper} from './MessageSearchResponseMapper';
import {searchExistingMessages} from './MessageSearchResultReconciler';
import {channelRequiresAgeVerification} from './SearchNsfwUtils';
const CHANNEL_INDEX_CHECK_CONCURRENCY = 32;
const CHANNEL_INDEX_JOB_ENQUEUE_CONCURRENCY = 16;
@@ -101,7 +102,7 @@ export class GlobalSearchService {
this.guildService.search.collectAccessibleGuildChannels(params.userId),
this.findDmScopeContextChannel(params.userId, params.includeChannelId),
]);
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} = guildAccess;
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} = guildAccess;
if (unindexedChannelIds.size > 0) {
await this.queueIndexingChannels(unindexedChannelIds);
return {indexing: true};
@@ -133,7 +134,7 @@ export class GlobalSearchService {
if (guildIsAgeRestricted) {
return canIncludeNsfw;
}
if (channel.isNsfw) {
if (channelRequiresAgeVerification(channel, parentCategories, false)) {
return canIncludeNsfw;
}
return true;
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type ChannelID, createMessageID, type GuildID, type MessageID} from '../BrandedTypes';
import {type ChannelID, createMessageID, type MessageID} from '../BrandedTypes';
import {Logger} from '../Logger';
import {getMessageSearchService} from '../SearchFactory';
import type {IMessageSearchService} from './IMessageSearchService';
@@ -59,18 +59,3 @@ export async function deleteChannelMessageSearchDocuments(
);
}
}
export async function deleteGuildMessageSearchDocuments(guildId: GuildID, options: CleanupOptions = {}): Promise<void> {
const searchService = resolveMessageSearchService(options.searchService);
if (!searchService) {
return;
}
try {
await searchService.deleteGuildMessages(guildId);
} catch (error) {
Logger.error(
{...options.context, guildId: guildId.toString(), error},
'Failed to delete guild message documents from search index',
);
}
}
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
import {channelToContentWarningView, computeEffectiveChannelNsfw} from '../channel/utils/EffectiveContentWarning';
import type {Channel} from '../models/Channel';
export function channelRequiresAgeVerification(
channel: Channel,
channelsById: ReadonlyMap<string, Channel>,
guildNsfw: boolean,
): boolean {
const parentCategory =
channel.parentId != null ? (channelsById.get(channel.parentId.toString()) ?? null) : null;
return computeEffectiveChannelNsfw(
channelToContentWarningView(channel),
parentCategory ? channelToContentWarningView(parentCategory) : null,
{nsfw: guildNsfw, contentWarningLevel: ContentWarningLevel.INHERIT, contentWarningText: null},
);
}
@@ -123,6 +123,9 @@ export class StripeDisputeWebhookHandler {
reason: 'gift_refund',
chargeId: charge.id,
});
} else if (!giftCode.revokedAt) {
await this.userRepository.revokeGiftCode(giftCode.code);
Logger.debug({giftCode: giftCode.code, chargeId: charge.id}, 'Revoked unredeemed gift code after refund');
}
return;
}
@@ -196,6 +199,12 @@ export class StripeDisputeWebhookHandler {
{giftCode: giftCode.code, redeemerId: giftCode.redeemedByUserId},
'Premium revoked due to gift chargeback',
);
} else if (!giftCode.revokedAt) {
await this.userRepository.revokeGiftCode(giftCode.code);
Logger.debug(
{giftCode: giftCode.code, chargeId: extractId(dispute.charge)},
'Revoked unredeemed gift code after chargeback',
);
}
await this.paymentFraudService.enforceAccountFraudAction({
userId: giftCode.createdByUserId,
@@ -40,7 +40,7 @@ export class StripeGiftService {
async getGiftCode(code: string): Promise<GiftCode> {
const giftCode = await this.userRepository.findGiftCode(code);
if (!giftCode) {
if (!giftCode || giftCode.revokedAt) {
throw new UnknownGiftCodeError();
}
return giftCode;
@@ -62,7 +62,7 @@ export class StripeGiftService {
}
try {
const giftCode = await this.userRepository.findGiftCode(code);
if (!giftCode) {
if (!giftCode || giftCode.revokedAt) {
Logger.debug({userId, giftCode: code}, 'Gift code not found during redemption');
throw new UnknownGiftCodeError();
}
@@ -258,6 +258,7 @@ export class StripeGiftService {
const redeemedGracePeriodMs = 7 * 24 * 60 * 60 * 1000;
const cutoff = Date.now() - redeemedGracePeriodMs;
return gifts
.filter((gift) => gift.revokedAt === null)
.filter((gift) => gift.redeemedAt === null || gift.redeemedAt.getTime() > cutoff)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
@@ -252,7 +252,7 @@ export class StripeRefundService {
return;
}
const user = await this.userRepository.findUnique(userId);
if (!user || user.firstRefundAt) {
if (!user) {
return;
}
const subscriptionId = refund.metadata.subscription_id;
@@ -439,7 +439,7 @@ describe('Stripe Webhook - Invoice Events', () => {
test('skips zero-amount subscription_update invoice without granting an extra monthly cycle', async () => {
const account = await createTestAccount(harness);
const subscriptionId = `sub_test_${Date.now()}`;
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
@@ -481,7 +481,7 @@ describe('Stripe Webhook - Invoice Events', () => {
test('skips paid subscription_update invoices so interval switches do not grant extra time', async () => {
const account = await createTestAccount(harness);
const subscriptionId = `sub_test_${Date.now()}`;
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
@@ -37,6 +37,7 @@ export class TestMediaService extends IMediaService {
height: 128,
animated: stored ? this.isAnimatedImage(stored, format) : format === 'gif',
nsfw: false,
placeholder: this.fakePlaceholder(request.key),
};
}
if (request.type === 'upload') {
@@ -55,6 +56,7 @@ export class TestMediaService extends IMediaService {
height: 128,
animated: format === 'gif',
nsfw: false,
placeholder: this.fakePlaceholder(request.upload_filename),
};
}
if (request.type === 'external') {
@@ -67,11 +69,16 @@ export class TestMediaService extends IMediaService {
height: 128,
animated: false,
nsfw: false,
placeholder: this.fakePlaceholder(request.url),
};
}
return null;
}
private fakePlaceholder(seed: string): string {
return crypto.createHash('sha256').update(seed).digest('base64').slice(0, 24);
}
getExternalMediaProxyURL(): string {
return 'https://media-proxy.test';
}
@@ -47,6 +47,7 @@ function normaliseGiftCodeRowForWrite(data: GiftCodeRow): GiftCodeRow {
duration_type: durationType,
duration_quantity: durationQuantity,
duration_months: durationMonths,
revoked_at: data.revoked_at ?? null,
};
}
@@ -171,6 +172,10 @@ export class GiftCodeRepository {
await batch.execute();
}
async revokeGiftCode(code: string): Promise<void> {
await upsertOne(GiftCodes.patchByPk({code}, {revoked_at: Db.set(new Date())}));
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
const batch = new BatchBuilder();
const patch: Record<string, DbOp<unknown>> = {};
@@ -37,6 +37,7 @@ export interface IUserContentRepository {
findGiftCodesByRedeemer(userId: UserID): Promise<Array<GiftCode>>;
redeemGiftCode(code: string, userId: UserID): Promise<void>;
unredeemGiftCode(code: string, userId: UserID): Promise<void>;
revokeGiftCode(code: string): Promise<void>;
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
@@ -62,6 +62,10 @@ export class UserContentRepository implements IUserContentRepository {
return this.giftCodeRepository.unredeemGiftCode(code, userId);
}
async revokeGiftCode(code: string): Promise<void> {
return this.giftCodeRepository.revokeGiftCode(code);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.giftCodeRepository.updateGiftCode(code, data);
}
@@ -645,6 +645,10 @@ export class UserRepository implements IUserRepositoryAggregate {
return this.contentRepo.unredeemGiftCode(code, userId);
}
async revokeGiftCode(code: string): Promise<void> {
return this.contentRepo.revokeGiftCode(code);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.contentRepo.updateGiftCode(code, data);
}
@@ -118,7 +118,7 @@ export class TokenRepository {
}
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
await upsertOne(EmailRevertTokens.insert(tokenData));
await upsertOne(EmailRevertTokens.insertWithTtl(tokenData, seconds('24 hours')));
return new EmailRevertToken(tokenData);
}
@@ -89,14 +89,17 @@ export class UserAccountLookupService {
let guildMember: GuildMemberResponse | null = null;
let guildMemberDomain: GuildMember | null = null;
if (guildId != null) {
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
if (guildMemberDomain) {
guildMember = await this.deps.guildService.members.getMember({
userId,
targetId,
guildId,
requestCache,
});
const viewerMember = await this.deps.guildRepository.getMember(guildId, userId);
if (viewerMember) {
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
if (guildMemberDomain) {
guildMember = await this.deps.guildService.members.getMember({
userId,
targetId,
guildId,
requestCache,
});
}
}
}
let premiumType = user.premiumType ?? undefined;
@@ -118,15 +118,17 @@ export class UserRelationshipService {
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
if (!staffForceAccept && (await getInstanceConfigRepository().getInstancePolicyConfig()).direct_messages_disabled) {
const requesterUser = await this.userRepository.findUnique(userId);
const requesterIsStaff =
requesterUser != null && (requesterUser.flags & UserFlags.STAFF) === UserFlags.STAFF;
if (
!requesterIsStaff &&
(await getInstanceConfigRepository().getInstancePolicyConfig()).direct_messages_disabled
) {
throw new DirectMessagesDisabledError();
}
const requesterUser = await this.userRepository.findUnique(userId);
if (staffForceAccept) {
const requesterIsStaff = requesterUser != null && (requesterUser.flags & UserFlags.STAFF) === UserFlags.STAFF;
if (requesterIsStaff) {
return await this.forceCreateFriendship({userId, targetId, userCacheService, requestCache});
}
if (staffForceAccept && requesterIsStaff) {
return await this.forceCreateFriendship({userId, targetId, userCacheService, requestCache});
}
if (!requesterUser) {
throw new UnknownUserError();
@@ -3,12 +3,15 @@
import {MessageAttachmentFlags} from '@fluxer/constants/src/ChannelConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {AttachmentDecayRepository} from '../../attachment/AttachmentDecayRepository';
import {createAttachmentID} from '../../BrandedTypes';
import {createAttachmentID, createMemeID, createUserID} from '../../BrandedTypes';
import {
createTestAccountForAttachmentTests,
sendMessageWithAttachments,
setupTestGuildAndChannel,
} from '../../channel/tests/AttachmentTestUtils';
import {fetchOne} from '../../database/CassandraQueryExecution';
import {Db} from '../../database/CassandraTypes';
import {FavoriteMemes} from '../../Tables';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
@@ -42,6 +45,24 @@ interface MessageWithDecayAttachment {
}>;
}
interface MessageWithPlaceholderAttachment {
id: string;
attachments: Array<{
id: string;
filename: string;
placeholder?: string | null;
}>;
}
async function clearFavoriteMemePlaceholder(userId: string, memeId: string) {
await fetchOne(
FavoriteMemes.patchByPk(
{user_id: createUserID(BigInt(userId)), meme_id: createMemeID(BigInt(memeId))},
{placeholder: Db.set(null)},
),
);
}
async function fetchDecayRow(attachmentId: string) {
return new AttachmentDecayRepository().fetchById(createAttachmentID(BigInt(attachmentId)));
}
@@ -111,6 +132,43 @@ describe('Favorite Meme Operations', () => {
expect(sent.attachments[0].filename).toBe(filename);
expect(sent.attachments[0].flags & MessageAttachmentFlags.IS_ANIMATED).toBe(MessageAttachmentFlags.IS_ANIMATED);
});
test('should carry the saved placeholder onto the sent attachment', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Placeholder Meme',
});
expect(meme.placeholder).toBeTruthy();
const sent = await createBuilder<MessageWithPlaceholderAttachment>(harness, account.token)
.post(`/channels/${channel.id}/messages`)
.body({favorite_meme_id: meme.id})
.expect(HTTP_STATUS.OK)
.execute();
expect(sent.attachments[0].placeholder).toBe(meme.placeholder);
});
test('should read-repair a missing placeholder when sending a favorite meme', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Repair Meme',
});
expect(meme.placeholder).toBeTruthy();
await clearFavoriteMemePlaceholder(account.userId, meme.id);
const stripped = await getFavoriteMeme(harness, account.token, meme.id);
expect(stripped.placeholder).toBeNull();
const sent = await createBuilder<MessageWithPlaceholderAttachment>(harness, account.token)
.post(`/channels/${channel.id}/messages`)
.body({favorite_meme_id: meme.id})
.expect(HTTP_STATUS.OK)
.execute();
expect(sent.attachments[0].placeholder).toBeTruthy();
const repaired = await getFavoriteMeme(harness, account.token, meme.id);
expect(repaired.placeholder).toBe(sent.attachments[0].placeholder);
});
test('should create decay metadata when sending favorite meme', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
+43 -1
View File
@@ -142,7 +142,15 @@ export class WebhookService {
async getGuildWebhooks({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<Array<Webhook>> {
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_WEBHOOKS);
return await this.repository.listByGuild(guildId);
const webhooks = await this.repository.listByGuild(guildId);
const visibility = await Promise.all(
webhooks.map((webhook) =>
webhook.channelId
? this.canManageChannelWebhooks({userId, guildId, channelId: webhook.channelId})
: Promise.resolve(false),
),
);
return webhooks.filter((_webhook, index) => visibility[index]);
}
async getChannelWebhooks({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<Array<Webhook>> {
@@ -153,6 +161,7 @@ export class WebhookService {
guildId: channel.guildId,
});
await checkPermission(Permissions.MANAGE_WEBHOOKS);
await this.assertChannelWebhookPermission({userId, guildId: channel.guildId, channelId});
return await this.repository.listByChannel(channelId);
}
@@ -172,6 +181,7 @@ export class WebhookService {
guildId: channel.guildId,
});
await checkPermission(Permissions.MANAGE_WEBHOOKS);
await this.assertChannelWebhookPermission({userId, guildId: channel.guildId, channelId});
const guildLimit = this.resolveWebhookLimit(guildData.features, 'max_webhooks_per_guild', MAX_WEBHOOKS_PER_GUILD);
const guildWebhookCount = await this.repository.countByGuild(channel.guildId);
if (guildWebhookCount >= guildLimit) {
@@ -451,9 +461,41 @@ export class WebhookService {
if (!webhook) throw new UnknownWebhookError();
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId: webhook.guildId!});
await checkPermission(Permissions.MANAGE_WEBHOOKS);
if (webhook.guildId && webhook.channelId) {
await this.assertChannelWebhookPermission({
userId,
guildId: webhook.guildId,
channelId: webhook.channelId,
});
}
return webhook;
}
private async canManageChannelWebhooks({
userId,
guildId,
channelId,
}: {
userId: UserID;
guildId: GuildID;
channelId: ChannelID;
}): Promise<boolean> {
const [canView, canManage] = await Promise.all([
this.gatewayService.checkPermission({guildId, userId, permission: Permissions.VIEW_CHANNEL, channelId}),
this.gatewayService.checkPermission({guildId, userId, permission: Permissions.MANAGE_WEBHOOKS, channelId}),
]);
return canView && canManage;
}
private async assertChannelWebhookPermission(params: {
userId: UserID;
guildId: GuildID;
channelId: ChannelID;
}): Promise<void> {
const allowed = await this.canManageChannelWebhooks(params);
if (!allowed) throw new MissingPermissionsError();
}
private async getTokenAuthenticatedWebhook({webhookId, token}: WebhookTokenParams): Promise<Webhook> {
const webhook = await this.repository.findByToken(webhookId, token);
if (!webhook) throw new UnknownWebhookError();
@@ -9,28 +9,28 @@ import {getWorkerDependencies} from '../WorkerContext';
const PayloadSchema = z.object({
guildId: z.string(),
userId: z.string(),
days: z.number().min(0).max(7),
seconds: z.number().min(0).max(604800),
});
const deleteUserMessagesInGuildByTime: WorkerTaskHandler = async (payload, helpers) => {
const validated = PayloadSchema.parse(payload);
helpers.logger.debug({payload: validated}, 'Processing deleteUserMessagesInGuildByTime task');
const guildId = createGuildID(BigInt(validated.guildId));
const userId = createUserID(BigInt(validated.userId));
const {days} = validated;
const {seconds} = validated;
Logger.debug(
{guildId: guildId.toString(), userId: userId.toString(), days},
{guildId: guildId.toString(), userId: userId.toString(), seconds},
'Starting time-based message deletion for guild ban',
);
try {
const {channelService} = getWorkerDependencies();
await channelService.messages.deletion.deleteUserMessagesInGuild({guildId, userId, days});
await channelService.messages.deletion.deleteUserMessagesInGuild({guildId, userId, seconds});
Logger.debug(
{guildId: guildId.toString(), userId: userId.toString(), days},
{guildId: guildId.toString(), userId: userId.toString(), seconds},
'Time-based message deletion completed successfully',
);
} catch (error) {
Logger.error(
{guildId: guildId.toString(), userId: userId.toString(), days, error},
{guildId: guildId.toString(), userId: userId.toString(), seconds, error},
'Failed to delete user messages in guild',
);
throw error;
@@ -21,6 +21,7 @@ import {
} from '../../SearchFactory';
import type {IGuildMemberSearchService} from '../../search/IGuildMemberSearchService';
import type {IMessageSearchService} from '../../search/IMessageSearchService';
import {deleteChannelMessageSearchDocuments} from '../../search/MessageSearchIndexCleanup';
import {getWorkerDependencies} from '../WorkerContext';
const INDEX_TYPES = [
@@ -190,11 +191,16 @@ const refreshChannelMessages: IndexHandler = async (payload, helpers, kvClient,
const {channelRepository} = getWorkerDependencies();
const guildId = createGuildID(BigInt(payload.guild_id!));
const searchService = requireSearchService<IMessageSearchService>(getMessageSearchService());
await searchService.deleteGuildMessages(guildId);
const channels = await channelRepository.listGuildChannels(guildId);
if (channels.length === 0) {
return 0;
}
for (const channel of channels) {
await deleteChannelMessageSearchDocuments(channel.id, {
searchService,
context: {source: 'bulk_reindex', guildId: guildId.toString()},
});
}
const completionKey = `bulk_reindex:${payload.job_id}:remaining`;
await kvClient.del(completionKey);
for (const channel of channels) {
@@ -33,6 +33,7 @@ export const ZOOM_LEVEL_MARKERS = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] as cons
export const ACCESSIBILITY_STORE_STORAGE_KEY = 'Accessibility';
export const ACCESSIBILITY_ZOOM_STORAGE_KEY = 'Accessibility:zoomLevel';
export const ACCESSIBILITY_CUSTOM_THEME_STORAGE_KEY = 'Accessibility:customThemeCss';
const ACCESSIBILITY_CUSTOM_THEME_SYNC_STORAGE_KEY = 'Accessibility:customThemeCssSyncAcrossDevices';
export const ACCESSIBILITY_MOTION_STORAGE_KEY = 'Accessibility:motion';
export const ACCESSIBILITY_SHOW_NEKO_STORAGE_KEY = 'Accessibility:showNeko';
export const ACCESSIBILITY_KEEP_NEKO_STILL_STORAGE_KEY = 'Accessibility:keepNekoStill';
@@ -492,6 +493,21 @@ function persistLocalCustomThemeCss(css: string | null): void {
} catch {}
}
function persistLocalCustomThemeCssSyncAcrossDevices(value: boolean): void {
try {
AppStorage.setItem(ACCESSIBILITY_CUSTOM_THEME_SYNC_STORAGE_KEY, JSON.stringify(value));
} catch {}
}
function readLocalCustomThemeCssSyncAcrossDevices(): boolean {
try {
const raw = AppStorage.getItem(ACCESSIBILITY_CUSTOM_THEME_SYNC_STORAGE_KEY);
return raw === null ? false : JSON.parse(raw) === true;
} catch {
return false;
}
}
function normalizeCustomThemeCss(css: string | null | undefined): string | null {
if (typeof css !== 'string') {
return null;
@@ -728,6 +744,7 @@ class Accessibility {
if (this.customThemeCss !== null) {
persistLocalCustomThemeCss(this.customThemeCss);
}
this.customThemeCssSyncAcrossDevices = readLocalCustomThemeCssSyncAcrossDevices();
this.showNeko = readAndMigrateLocalShowNeko();
this.keepNekoStill = readAndMigrateLocalKeepNekoStill();
this.showVideoSeekPreviewThumbnails = readLocalVideoSeekPreviewThumbnails();
@@ -851,7 +868,11 @@ class Accessibility {
showStickersInAutocomplete: s.showStickersInExpressionAutocomplete,
showMemesInAutocomplete: s.showMemesInExpressionAutocomplete,
voiceChannelJoinRequiresDoubleClick: s.voiceChannelJoinRequiresDoubleClick,
customThemeCss: s.customThemeCssSyncAcrossDevices ? (s.customThemeCss ?? '') : (s.serverCustomThemeCss ?? ''),
customThemeCss: ((): string | undefined => {
const local = s.customThemeCss;
const server = s.serverCustomThemeCss;
return (s.customThemeCssSyncAcrossDevices ? local : server) ?? undefined;
})(),
showFavorites: s.showFavorites,
dmMessagePreviewMode: DM_PREVIEW_TO_PROTO[s.dmMessagePreviewMode],
enableTtsCommand: s.enableTTSCommand,
@@ -1396,6 +1417,7 @@ class Accessibility {
persistLocalCustomThemeCss(this.customThemeCss);
}
this.customThemeCssSyncAcrossDevices = syncAcrossDevices;
persistLocalCustomThemeCssSyncAcrossDevices(syncAcrossDevices);
}
subscribe(callback: () => void): () => void {
@@ -278,10 +278,6 @@ const DEVELOPER_OPTION_DESCRIPTOR = msg({
message: 'Developer option',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
const GAME_CAPTURE_INJECTION_METHOD_DESCRIPTOR = msg({
message: 'Game capture injection (Windows)',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
const DEVELOPER_OPTION_LABEL_FALLBACKS: Partial<Record<keyof DeveloperOptionsState, MessageDescriptor>> = {
mockAttachmentStates: ATTACHMENT_MOCKS_DESCRIPTOR,
};
@@ -327,7 +323,6 @@ const formatDeveloperOptionValue = <K extends keyof DeveloperOptionsState>(
case 'mockRequiredActionsResendOutcome':
case 'mockTitlebarPlatformOverride':
case 'mockUpdaterState':
case 'gameCaptureInjectionMethod':
return String(value).replace(/_/g, ' ');
case 'premiumSinceOverride':
case 'premiumUntilOverride':
@@ -410,8 +405,6 @@ export const getDeveloperOptionLabel = (key: keyof DeveloperOptionsState): Messa
return VANITY_URL_DISCLAIMER_DESCRIPTOR;
case 'forceShowVoiceConnection':
return VOICE_CONNECTION_DEBUG_DESCRIPTOR;
case 'gameCaptureInjectionMethod':
return GAME_CAPTURE_INJECTION_METHOD_DESCRIPTOR;
case 'premiumTypeOverride':
return PREMIUM_TYPE_DESCRIPTOR;
case 'premiumLifetimeSequenceOverride':
@@ -1,15 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getDeveloperOptionLabel} from '@app/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels';
import {DeveloperOptionRadioSubmenu} from '@app/features/channel/components/channel_header_components/developer_tools/DeveloperToolsMenuComponents';
import {translateDescriptor} from '@app/features/channel/components/channel_header_components/developer_tools/DeveloperToolsShared';
import {getGameCaptureInjectionMethodOptions} from '@app/features/channel/components/channel_header_components/developer_tools/OptionPresets';
import {getToggleGroups, type ToggleGroup} from '@app/features/devtools/components/DeveloperOptionsToggleGroups';
import type {DeveloperOptionsState} from '@app/features/devtools/state/DeveloperOptions';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import {CheckboxItem} from '@app/features/ui/action_menu/ContextMenu';
import {MenuItemSubmenu} from '@app/features/ui/action_menu/MenuItemSubmenu';
import {getNativePlatformSync} from '@app/features/ui/utils/NativeUtils';
import * as UserSettingsCommands from '@app/features/user/commands/UserSettingsCommands';
import UserSettings from '@app/features/user/state/UserSettings';
import Users from '@app/features/user/state/Users';
@@ -71,7 +67,6 @@ export const GeneralDeveloperOptionsMenu: React.FC = observer(() => {
const currentUser = Users.currentUser;
const canConfigureMentionSuppression = currentUser?.isStaff() ?? false;
const suppressUnprivilegedSelfMentions = UserSettings.getSuppressUnprivilegedSelfMentions();
const isWindows = getNativePlatformSync() === 'windows';
return (
<>
{toggleGroups.map((group, index) => (
@@ -81,14 +76,6 @@ export const GeneralDeveloperOptionsMenu: React.FC = observer(() => {
data-flx="channel.channel-header-components.developer-tools-context-menu.general-developer-options-menu.toggle-group-submenu"
/>
))}
{isWindows && (
<DeveloperOptionRadioSubmenu
label={translateDescriptor(i18n, getDeveloperOptionLabel('gameCaptureInjectionMethod'))}
optionKey="gameCaptureInjectionMethod"
options={getGameCaptureInjectionMethodOptions()}
data-flx="channel.channel-header-components.developer-tools-context-menu.general-developer-options-menu.game-capture-injection"
/>
)}
{canConfigureMentionSuppression && (
<MenuItemSubmenu
label={i18n._(MENTION_CONTROLS_DESCRIPTOR)}
@@ -196,14 +196,3 @@ export const getGiftDurationOptions = (): Array<RadioMenuOption<DeveloperOptions
{value: 12, label: MESSAGE_12_MONTHS_1_YEAR_DESCRIPTOR},
{value: 0, label: LIFETIME_DESCRIPTOR},
];
export const INJECT_METHOD_AUTOMATIC_DESCRIPTOR = msg({
message: 'Automatic',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
export const getGameCaptureInjectionMethodOptions = (): Array<
RadioMenuOption<DeveloperOptionsState['gameCaptureInjectionMethod']>
> => [
{value: 'auto', label: INJECT_METHOD_AUTOMATIC_DESCRIPTOR},
{value: 'remote-thread', label: 'CreateRemoteThread'},
{value: 'set-windows-hook', label: 'SetWindowsHookEx'},
];
@@ -69,7 +69,6 @@ export const DEFAULT_DEVELOPER_OPTIONS = {
mockTitlebarPlatformOverride: 'auto',
mockAttachmentStates: {},
noOpInAppReports: false,
gameCaptureInjectionMethod: 'auto',
} satisfies DeveloperOptionsState;
const PREMIUM_SCENARIO_OVERRIDE_KEYS = new Set<keyof DeveloperOptionsState>([
'premiumTypeOverride',
@@ -408,7 +408,7 @@ export const EmbedGifv: FC<
},
[visibilityRef],
);
const {dimensions, style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight});
const {dimensions} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight});
const canPlayInline = isInlinePlayableVideoSize({width: naturalWidth, height: naturalHeight});
const posterSource = thumbnailProxyURL && thumbnailProxyURL.length > 0 ? thumbnailProxyURL : videoProxyURL;
const posterURL = useMemo(() => {
@@ -534,8 +534,11 @@ export const EmbedGifv: FC<
safePause(video);
}, [shouldPlay]);
if (shouldBlur) {
const {width: _width, height: _height, ...styleWithoutDimensions} = style;
const blurContainerStyle = {...styleWithoutDimensions, maxWidth: '100%', width: '100%'};
const blurContainerStyle: React.CSSProperties = {
maxWidth: '100%',
width: remFromPx(dimensions.width),
...aspectRatioStyle,
};
return (
<div
ref={visibilityRef}
@@ -719,7 +722,7 @@ export const EmbedGif: FC<
},
[visibilityRef],
);
const {dimensions, style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight});
const {dimensions} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight});
const {width: displayWidth, height: displayHeight} = dimensions;
const gifAutoPlay = useShouldAnimate({kind: 'gif'});
const animationPolicyAllowed = useShouldAnimate({
@@ -856,8 +859,11 @@ export const EmbedGif: FC<
],
);
if (shouldBlur) {
const {width: _width, height: _height, ...styleWithoutDimensions} = style;
const blurContainerStyle = {...styleWithoutDimensions, maxWidth: '100%', width: '100%'};
const blurContainerStyle: React.CSSProperties = {
maxWidth: '100%',
width: remFromPx(dimensions.width),
...aspectRatioStyle,
};
return (
<div
ref={visibilityRef}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {BAN_DELETE_MESSAGE_OPTIONS} from '@app/features/moderation/constants/BanDeleteMessageOptions';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -83,39 +84,6 @@ const COMMAND_DELETE_MESSAGES_OPTION_DESCRIPTOR = msg({
message: "How much of the member's recent message history to delete.",
comment: 'Description for the /ban delete_messages option.',
});
const DELETE_MESSAGES_NONE_DESCRIPTOR = msg({
message: "Don't delete any",
comment: 'Choice label for retaining all messages when banning a member.',
});
const DELETE_MESSAGES_ONE_DAY_DESCRIPTOR = msg({
message: 'Previous 24 hours',
comment: 'Choice label for deleting one day of messages when banning a member.',
});
const DELETE_MESSAGES_TWO_DAYS_DESCRIPTOR = msg({
message: 'Previous 2 days',
comment: 'Choice label for deleting two days of messages when banning a member.',
});
const DELETE_MESSAGES_THREE_DAYS_DESCRIPTOR = msg({
message: 'Previous 3 days',
comment: 'Choice label for deleting three days of messages when banning a member.',
});
const DELETE_MESSAGES_FOUR_DAYS_DESCRIPTOR = msg({
message: 'Previous 4 days',
comment: 'Choice label for deleting four days of messages when banning a member.',
});
const DELETE_MESSAGES_FIVE_DAYS_DESCRIPTOR = msg({
message: 'Previous 5 days',
comment: 'Choice label for deleting five days of messages when banning a member.',
});
const DELETE_MESSAGES_SIX_DAYS_DESCRIPTOR = msg({
message: 'Previous 6 days',
comment: 'Choice label for deleting six days of messages when banning a member.',
});
const DELETE_MESSAGES_SEVEN_DAYS_DESCRIPTOR = msg({
message: 'Previous 7 days',
comment: 'Choice label for deleting seven days of messages when banning a member.',
});
interface SimpleCommand {
type: 'simple';
name: string;
@@ -271,16 +239,10 @@ export function useCommands(): Array<Command> {
type: 'choice',
required: true,
allowEmpty: false,
choices: [
{name: i18n._(DELETE_MESSAGES_NONE_DESCRIPTOR), value: '0'},
{name: i18n._(DELETE_MESSAGES_ONE_DAY_DESCRIPTOR), value: '1'},
{name: i18n._(DELETE_MESSAGES_TWO_DAYS_DESCRIPTOR), value: '2'},
{name: i18n._(DELETE_MESSAGES_THREE_DAYS_DESCRIPTOR), value: '3'},
{name: i18n._(DELETE_MESSAGES_FOUR_DAYS_DESCRIPTOR), value: '4'},
{name: i18n._(DELETE_MESSAGES_FIVE_DAYS_DESCRIPTOR), value: '5'},
{name: i18n._(DELETE_MESSAGES_SIX_DAYS_DESCRIPTOR), value: '6'},
{name: i18n._(DELETE_MESSAGES_SEVEN_DAYS_DESCRIPTOR), value: '7'},
],
choices: BAN_DELETE_MESSAGE_OPTIONS.map((option) => ({
name: i18n._(option.label),
value: String(option.seconds),
})),
},
{
name: 'reason',
@@ -97,7 +97,6 @@ export type DeveloperOptionsState = Readonly<{
}
>;
noOpInAppReports: boolean;
gameCaptureInjectionMethod: 'auto' | 'remote-thread' | 'set-windows-hook';
}>;
type MutableDeveloperOptionsState = {
-readonly [K in keyof DeveloperOptionsState]: DeveloperOptionsState[K];
@@ -182,7 +181,6 @@ class DeveloperOptions implements DeveloperOptionsState {
mockGiftRedeemed: boolean | null = null;
mockTitlebarPlatformOverride: DeveloperOptionsState['mockTitlebarPlatformOverride'] = 'auto';
noOpInAppReports = false;
gameCaptureInjectionMethod: DeveloperOptionsState['gameCaptureInjectionMethod'] = 'auto';
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -255,7 +253,6 @@ class DeveloperOptions implements DeveloperOptionsState {
'mockTitlebarPlatformOverride',
'mockAttachmentStates',
'noOpInAppReports',
'gameCaptureInjectionMethod',
]);
}
@@ -7,6 +7,10 @@ import * as GuildMemberCommands from '@app/features/member/commands/GuildMemberC
import GuildMembers from '@app/features/member/state/GuildMembers';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {Message} from '@app/features/messaging/models/MessagingMessage';
import {
BAN_DELETE_MESSAGE_SECONDS_CHOICE_VALUES,
DEFAULT_BAN_DELETE_MESSAGE_SECONDS,
} from '@app/features/moderation/constants/BanDeleteMessageOptions';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {User} from '@app/features/user/models/User';
import Users from '@app/features/user/state/Users';
@@ -43,7 +47,7 @@ export type ParsedCommand =
| {
type: 'ban';
userId: string;
deleteMessageDays: number;
deleteMessageSeconds: number;
duration: number;
reason?: string;
}
@@ -101,11 +105,11 @@ export function parseCommand(content: string): ParsedCommand {
const userId = userMatch[1];
const afterMention = rest.slice(userMatch[0].length).trim();
const parts = afterMention.length === 0 ? [] : afterMention.split(/\s+/);
let deleteMessageDays = 1;
let deleteMessageSeconds = DEFAULT_BAN_DELETE_MESSAGE_SECONDS;
let reasonStart = 0;
const firstPart = parts[0];
if (firstPart !== undefined && /^[0-7]$/.test(firstPart)) {
deleteMessageDays = Number(firstPart);
if (firstPart !== undefined && BAN_DELETE_MESSAGE_SECONDS_CHOICE_VALUES.has(firstPart)) {
deleteMessageSeconds = Number(firstPart);
reasonStart = 1;
} else if (firstPart !== undefined && /^\d+$/.test(firstPart)) {
return {type: 'unknown'};
@@ -114,7 +118,7 @@ export function parseCommand(content: string): ParsedCommand {
const reasonParts = parts.slice(reasonStart);
const reasonText = reasonParts.join(' ').trim();
const reason = reasonText || undefined;
return {type: 'ban', userId, deleteMessageDays, duration, reason};
return {type: 'ban', userId, deleteMessageSeconds, duration, reason};
}
if (trimmed.startsWith('/msg ')) {
const rest = trimmed.slice(5).trim();
@@ -310,7 +314,7 @@ export async function executeCommand(
await GuildCommands.banMember(
guildId,
command.userId,
command.deleteMessageDays,
command.deleteMessageSeconds,
command.reason,
command.duration,
);
@@ -51,7 +51,7 @@ interface GuildTemplateCreateParams {
}
interface BanMemberRequest {
delete_message_days: number;
delete_message_seconds: number;
reason: string | null;
ban_duration_seconds?: number;
}
@@ -124,9 +124,13 @@ function transferOwnershipRequest(newOwnerId: string): {new_owner_id: string} {
return {new_owner_id: newOwnerId};
}
function banMemberRequest(deleteMessageDays?: number, reason?: string, banDurationSeconds?: number): BanMemberRequest {
function banMemberRequest(
deleteMessageSeconds?: number,
reason?: string,
banDurationSeconds?: number,
): BanMemberRequest {
return {
delete_message_days: deleteMessageDays ?? 0,
delete_message_seconds: deleteMessageSeconds ?? 0,
reason: reason ?? null,
ban_duration_seconds: banDurationSeconds,
};
@@ -373,13 +377,13 @@ export async function transferOwnership(guildId: string, newOwnerId: string): Pr
export async function banMember(
guildId: string,
userId: string,
deleteMessageDays?: number,
deleteMessageSeconds?: number,
reason?: string,
banDurationSeconds?: number,
): Promise<void> {
try {
await http.put(Endpoints.GUILD_BAN(guildId, userId), {
body: banMemberRequest(deleteMessageDays, reason, banDurationSeconds),
body: banMemberRequest(deleteMessageSeconds, reason, banDurationSeconds),
});
logger.debug(`Banned user ${userId} from guild ${guildId}`);
} catch (error) {
@@ -38987,3 +38987,38 @@ msgstr "تكبير"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "تصغير"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "عدم حذف أي شيء"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "الساعة السابقة"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "آخر 6 ساعات"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "آخر 12 ساعة"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "آخر 24 ساعة"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "آخر 3 أيام"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "آخر 7 أيام"
@@ -38987,3 +38987,38 @@ msgstr "Увеличаване"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Намаляване"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "Не изтривай нищо"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Предишен час"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "Последните 6 часа"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "През последните 12 часа"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "През последните 24 часа"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "Последните 3 дни"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "Предишни 7 дни"
@@ -38987,3 +38987,38 @@ msgstr "Přiblížit"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Oddálit"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "Neodstranit žádné"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Předchozí hodina"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "Posledních 6 hodin"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "Posledních 12 hodin"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "Posledních 24 hodin"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "Poslední 3 dny"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "Posledních 7 dní"
@@ -38987,3 +38987,38 @@ msgstr "Zoom ind"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Zoom ud"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "Slet ingen"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Sidste time"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "Seneste 6 timer"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "Seneste 12 timer"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "Seneste 24 timer"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "Seneste 3 dage"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "Seneste 7 dage"
@@ -38987,3 +38987,38 @@ msgstr "Vergrößern"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Verkleinern"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "Nichts löschen"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Letzte Stunde"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "Letzte 6 Stunden"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "Letzte 12 Stunden"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "Letzte 24 Stunden"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "Letzte 3 Tage"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "Letzte 7 Tage"
@@ -38987,3 +38987,38 @@ msgstr "Μεγέθυνση"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Σμίκρυνση"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "Να μην διαγραφεί τίποτα"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Προηγούμενη ώρα"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "Τελευταίες 6 ώρες"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "Τελευταίες 12 ώρες"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "Τελευταίες 24 ώρες"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "Τελευταίες 3 ημέρες"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "Προηγούμενες 7 ημέρες"
@@ -38988,3 +38988,38 @@ msgstr "Zoom in"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Zoom out"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "Don't delete any"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Previous hour"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "Previous 6 hours"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "Previous 12 hours"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "Previous 24 hours"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "Previous 3 days"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "Previous 7 days"
@@ -38988,3 +38988,38 @@ msgstr "Zoom in"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Zoom out"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "Don't Delete Any"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Previous Hour"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "Previous 6 Hours"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "Previous 12 Hours"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "Previous 24 Hours"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "Previous 3 Days"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "Previous 7 Days"
@@ -38987,3 +38987,38 @@ msgstr "Acercar"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Alejar"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "No borrar nada"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Última hora"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "Últimas 6 horas"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "Últimas 12 horas"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "Últimas 24 horas"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "Últimos 3 días"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "Últimos 7 días"
@@ -38987,3 +38987,38 @@ msgstr "Acercar"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Alejar"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "No eliminar"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Última hora"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "Últimas 6 horas"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "Últimas 12 horas"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "Últimas 24 horas"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "Últimos 3 días"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "Últimos 7 días"
@@ -38987,3 +38987,38 @@ msgstr "Lähennä"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Loitonna"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "Älä poista mitään"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Edellinen tunti"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "Edelliset 6 tuntia"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "Edelliset 12 tuntia"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "Edelliset 24 tuntia"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "Edelliset 3 päivää"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "Edelliset 7 päivää"
@@ -38987,3 +38987,38 @@ msgstr "Zoom avant"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Dézoomer"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "Ne pas supprimer"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Dernière heure"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "6 dernières heures"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "12 dernières heures"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "24 dernières heures"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "3 derniers jours"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "7 derniers jours"
@@ -38987,3 +38987,38 @@ msgstr "התקרבות"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "הקטן תצוגה"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "אל תמחק כלום"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "שעה קודמת"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "6 השעות האחרונות"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "12 השעות האחרונות"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "24 השעות האחרונות"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "3 הימים האחרונים"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "7 הימים האחרונים"
@@ -38987,3 +38987,38 @@ msgstr "ज़ूम इन करें"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "छोटा करें"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "कोई भी डिलीट न करें"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "पिछला घंटा"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "पिछले 6 घंटे"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "पिछले 12 घंटे"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "पिछले 24 घंटे"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "पिछले 3 दिन"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "पिछले 7 दिन"
@@ -38987,3 +38987,38 @@ msgstr "Povećaj"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Smanji prikaz"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "Ne briši ništa"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Prošli sat"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "Prethodnih 6 sati"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "Prošlih 12 sati"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "Prethodna 24 sata"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "Prethodna 3 dana"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "Prethodnih 7 dana"
@@ -38987,3 +38987,38 @@ msgstr "Nagyítás"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Kicsinyítés"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "Ne törölj semmit"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Előző óra"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "Előző 6 óra"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "Előző 12 óra"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "Előző 24 óra"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "Előző 3 nap"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "Előző 7 nap"
@@ -38987,3 +38987,38 @@ msgstr "Perbesar"
#: src/features/user/components/settings_utils/search_index/KeybindsIndex.ts:86
msgid "Zoom out"
msgstr "Perkecil"
#. Message-history-deletion option when banning a member. Keeps all of the member's messages.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:14
msgid "Don't Delete Any"
msgstr "Jangan Hapus Apa Pun"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last hour.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:21
msgid "Previous Hour"
msgstr "Satu Jam Terakhir"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 6 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:28
msgid "Previous 6 Hours"
msgstr "6 Jam Terakhir"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 12 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:36
msgid "Previous 12 Hours"
msgstr "12 Jam Terakhir"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 24 hours.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:44
msgid "Previous 24 Hours"
msgstr "24 Jam Terakhir"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 3 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:52
msgid "Previous 3 Days"
msgstr "3 Hari Terakhir"
#. Message-history-deletion option when banning a member. Deletes the member's messages from the last 7 days.
#: src/features/moderation/constants/BanDeleteMessageOptions.ts:60
msgid "Previous 7 Days"
msgstr "7 Hari Terakhir"

Some files were not shown because too many files have changed in this diff Show More