Compare commits

...
Author SHA1 Message Date
HampusandGitHub dbc63e9ef7 fix(voice): surface stream audio volume for screen-share viewers (#2095) 2026-08-29 22:05:06 +02:00
HampusandGitHub ce91ff95ab fix(app): render raw unicode emoji with OS color emoji fonts (#2094) 2026-08-29 21:09:08 +02:00
HampusandGitHub d75a29f099 feat(app): composer send-button toggle and active button hover (#2093) 2026-08-29 20:25:49 +02:00
HampusandGitHub cb889b1160 fix(api): correct apns clear payload and badge handling (#2092) 2026-08-29 20:07:18 +02:00
HampusandGitHub 8db4f5cb63 feat(app): full-Unicode font fallback with self-hosted Noto (#2091) 2026-08-29 19:27:47 +02:00
HampusandGitHub d297dc5805 feat(api): exempt APP_STORE_REVIEWER accounts from captcha (#2090) 2026-08-29 18:00:52 +02:00
HampusandGitHub 9b8659a40b fix(desktop): stop the updater button flickering after download (#2089) 2026-08-29 17:44:43 +02:00
HampusandGitHub 96c5db3f5d fix(markdown): open code blocks for fences after text (#2088) 2026-08-29 17:32:43 +02:00
HampusandGitHub 78e403819e fix(admin): set delete_message_seconds on ban request (#2087) 2026-08-29 16:35:40 +02:00
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
255 changed files with 3471 additions and 12653 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": {
+1
View File
@@ -136,6 +136,7 @@ impl AdminApiClient {
let body = generated_types::BanGuildMemberRequest {
ban_duration_seconds: None,
delete_message_days: None,
delete_message_seconds: None,
guild_id: snowflake(guild_id),
reason: None,
user_id: snowflake(user_id),
+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 {
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {Config} from '../../Config';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {createAuthHarness, createUniqueEmail, createUniqueUsername, registerUser} from './AuthTestUtils';
async function withCaptchaEnabled<T>(run: () => Promise<T>): Promise<T> {
const previousEnabled = Config.captcha.enabled;
const previousTestModeEnabled = Config.dev.testModeEnabled;
Config.captcha.enabled = true;
Config.dev.testModeEnabled = true;
try {
return await run();
} finally {
Config.captcha.enabled = previousEnabled;
Config.dev.testModeEnabled = previousTestModeEnabled;
}
}
async function registerAndFlag(
harness: ApiTestHarness,
flags: Array<string>,
): Promise<{email: string; password: string; userId: string}> {
const email = createUniqueEmail('captcha-flags');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('captchaflags'),
global_name: 'Captcha Flags User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
if (flags.length > 0) {
await createBuilderWithoutAuth(harness)
.post(`/test/users/${reg.user_id}/security-flags`)
.body({set_flags: flags})
.execute();
}
return {email, password, userId: reg.user_id};
}
describe('Auth Captcha Bypass Flags', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('lets APP_STORE_REVIEWER accounts log in without solving a captcha', async () => {
const account = await registerAndFlag(harness, ['APP_STORE_REVIEWER']);
await withCaptchaEnabled(async () => {
const resp = await createBuilderWithoutAuth<{token?: string; user_id?: string}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(resp.token).toBeTruthy();
expect(resp.user_id).toBe(account.userId);
});
});
it('still requires a captcha for accounts without the APP_STORE_REVIEWER flag', async () => {
const account = await registerAndFlag(harness, []);
await withCaptchaEnabled(async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute();
});
});
});
@@ -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});
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory';
@@ -53,7 +54,9 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
if (!captchaConfig.enabled && !(Config.dev.testModeEnabled && Config.captcha.enabled)) return;
const user = ctx.get('user') as User | undefined;
if (accountPolicyContactHasCapability(user?.email, 'captcha_exempt')) return;
if (userHasCaptchaExemptFlag(user)) return;
if (await requestContactHasCaptchaExemption(ctx.req.raw)) return;
if (await requestUserHasCaptchaExemptFlag(ctx)) return;
const token = ctx.req.header('x-captcha-token');
if (!token) {
throw new CaptchaRequiredError();
@@ -83,6 +86,23 @@ async function requestContactHasCaptchaExemption(request: Request): Promise<bool
}
}
function userHasCaptchaExemptFlag(user: User | null | undefined): boolean {
return user != null && (user.flags & UserFlags.APP_STORE_REVIEWER) !== 0n;
}
async function requestUserHasCaptchaExemptFlag(ctx: Context<HonoEnv>): Promise<boolean> {
try {
const body = (await ctx.req.raw.clone().json()) as unknown;
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
const email = (body as Record<string, unknown>).email;
if (typeof email !== 'string') return false;
const user = await ctx.get('userRepository').findByEmail(email);
return userHasCaptchaExemptFlag(user);
} catch {
return false;
}
}
export const CaptchaMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
await verifyCaptchaToken(ctx);
await next();
@@ -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"}],
+9 -14
View File
@@ -143,7 +143,7 @@ function buildApnsHeaders(params: {
'apns-topic': params.topic,
'apns-push-type': isClear ? APNS_PUSH_TYPE_BACKGROUND : APNS_PUSH_TYPE_ALERT,
'apns-priority': isClear ? '5' : '10',
'apns-expiration': String(Math.floor(Date.now() / 1000) + (isClear ? 300 : 86400)),
'apns-expiration': String(Math.floor(Date.now() / 1000) + (isClear ? 3600 : 86400)),
'content-type': 'application/json',
};
if (collapseId && Buffer.byteLength(collapseId) <= 64) {
@@ -155,18 +155,11 @@ function buildApnsHeaders(params: {
function buildApnsPayload(payload: Record<string, unknown>): Record<string, unknown> {
if (isClearNotificationPayload(payload)) {
const data = isRecord(payload.data) ? payload.data : {};
const badge = normalizeBadgeCount(data.badge_count);
const aps: Record<string, unknown> = {
'content-available': 1,
};
if (badge !== undefined) {
aps.badge = badge;
}
return {
...data,
type: 'notification_clear',
action: 'clear_channel',
aps,
aps: {'content-available': 1},
};
}
const data = isRecord(payload.data) ? payload.data : {};
@@ -177,16 +170,18 @@ function buildApnsPayload(payload: Record<string, unknown>): Record<string, unkn
const channelId = optionalString(data.channel_id);
const threadId =
optionalString(data.notification_tag) ?? (channelId ? `channel:${channelId}` : undefined) ?? 'fluxer-message';
const imageUrl = firstString([payload.image_url, notification.image, notification.icon, payload.icon]);
const imageUrl = firstString([payload.image_url, notification.image]);
const aps: Record<string, unknown> = {
alert: {title, body},
sound: APNS_DEFAULT_SOUND,
badge,
'thread-id': threadId,
category: APNS_CATEGORY_MESSAGE,
'interruption-level': 'active',
'relevance-score': 0.5,
};
if (badge !== undefined) {
aps.badge = badge;
}
if (imageUrl) {
aps['mutable-content'] = 1;
}
@@ -292,13 +287,13 @@ function isPermanentApnsFailure(statusCode: number, reason: string): boolean {
return reason === 'Unregistered';
}
function normalizeBadgeCount(value: unknown): number {
function normalizeBadgeCount(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
return Number.isFinite(parsed) ? Math.max(0, parsed) : undefined;
}
return 0;
return undefined;
}
function firstString(values: Array<unknown>): string | undefined {
@@ -38,7 +38,7 @@ describe('ApnsPushService', () => {
},
});
});
it('builds silent APNs clear payloads with badge when badge_count is present', () => {
it('builds silent APNs clear payloads as pure background pushes', () => {
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
type: 'notification_clear',
action: 'clear_channel',
@@ -56,11 +56,11 @@ describe('ApnsPushService', () => {
badge_count: 0,
aps: {
'content-available': 1,
badge: 0,
},
});
expect(payload.aps).not.toHaveProperty('alert');
expect(payload.aps).not.toHaveProperty('sound');
expect(payload.aps).not.toHaveProperty('badge');
});
it('uses APNs push-type and priority headers that match alert versus background delivery', () => {
const alertHeaders = ApnsPushServiceTestHooks.buildApnsHeaders({
@@ -94,6 +94,43 @@ describe('ApnsPushService', () => {
'apns-priority': '5',
'apns-collapse-id': 'channel:123',
});
const now = Math.floor(Date.now() / 1000);
expect(Number(clearHeaders['apns-expiration']) - now).toBeGreaterThan(1800);
});
it('omits the badge entirely when no usable badge count is supplied', () => {
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
tag: 'channel:123:456',
data: {channel_id: '123', message_id: '456'},
notification: {title: 'Alice', body: 'Hello'},
});
expect(payload.aps).not.toHaveProperty('badge');
const unparseable = ApnsPushServiceTestHooks.buildApnsPayload({
tag: 'channel:123:456',
data: {channel_id: '123', message_id: '456', badge_count: 'not-a-number'},
notification: {title: 'Alice', body: 'Hello'},
});
expect(unparseable.aps).not.toHaveProperty('badge');
});
it('keeps a genuine zero badge so reading the last message clears the app icon', () => {
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
tag: 'channel:123:456',
data: {channel_id: '123', message_id: '456', badge_count: 0},
notification: {title: 'Alice', body: 'Hello'},
});
expect((payload.aps as Record<string, unknown>).badge).toBe(0);
});
it('does not use the sender avatar as the notification media image', () => {
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
tag: 'channel:123:456',
data: {channel_id: '123', message_id: '456', author_avatar_url: 'https://cdn.example/avatar.png'},
notification: {title: 'Alice', body: 'Hello', icon: 'https://cdn.example/avatar.png'},
});
expect(payload.image_url).toBeUndefined();
expect(payload.aps).not.toHaveProperty('mutable-content');
expect(payload.author_avatar_url).toBe('https://cdn.example/avatar.png');
});
it('marks only permanent APNs token failures as subscription deletion signals', () => {
expect(ApnsPushServiceTestHooks.isPermanentApnsFailure(410, 'Unregistered')).toBe(true);
@@ -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) {
+89
View File
@@ -0,0 +1,89 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
:root {
--font-fallback-sans:
'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji',
'Fluxer Fallback', 'Fluxer Symbols', 'Fluxer Math',
'Noto Sans', 'Nirmala UI',
'Noto Sans Bengali', 'Noto Sans Tamil', 'Noto Sans Telugu', 'Noto Sans Kannada', 'Noto Sans Malayalam',
'Noto Sans Gujarati', 'Noto Sans Gurmukhi', 'Noto Sans Oriya', 'Noto Sans Sinhala',
'Noto Sans Khmer', 'Noto Sans Lao', 'Noto Sans Myanmar', 'Noto Sans Tibetan',
'Noto Sans Armenian', 'Noto Sans Georgian', 'Noto Sans Ethiopic', 'Noto Sans Cherokee',
'Noto Sans Canadian Aboriginal', 'Noto Sans Mongolian', 'Noto Sans Vai', 'Noto Sans NKo',
'Noto Sans Adlam', 'Noto Sans Yi', 'Noto Sans Osage', 'Noto Sans Thaana',
'Noto Sans CJK SC', 'Noto Sans CJK TC', 'Noto Sans CJK JP', 'Noto Sans CJK KR',
'Segoe UI Symbol', 'Segoe UI Historic', 'Apple Symbols',
'Noto Sans Symbols', 'Noto Sans Symbols 2', 'Noto Sans Math', 'Cambria Math';
--font-fallback-mono:
'Noto Sans Mono',
'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR',
var(--font-fallback-sans);
--font-sans:
'Fluxer Sans', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari', 'Fluxer Sans Thai Looped',
'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
BlinkMacSystemFont, 'Segoe UI', 'Roboto', var(--font-fallback-sans), sans-serif;
--font-mono: 'Fluxer Mono', 'Menlo', 'Monaco', 'Courier New', var(--font-fallback-mono), monospace;
}
:root:lang(ja) {
--font-sans:
'Fluxer Sans', 'Fluxer Sans JP', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari',
'Fluxer Sans Thai Looped', 'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans KR', system-ui, -apple-system,
BlinkMacSystemFont, 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Meiryo',
var(--font-fallback-sans), sans-serif;
}
:root:lang(ko) {
--font-sans:
'Fluxer Sans', 'Fluxer Sans KR', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari',
'Fluxer Sans Thai Looped', 'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', system-ui, -apple-system,
BlinkMacSystemFont, 'Malgun Gothic', 'Apple SD Gothic Neo', var(--font-fallback-sans), sans-serif;
}
:root:lang(zh-CN),
:root:lang(zh-Hans) {
--font-sans:
'Fluxer Sans', 'Fluxer Sans SC', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari',
'Fluxer Sans Thai Looped', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', var(--font-fallback-sans), sans-serif;
}
:root:lang(zh-Hant-TW),
:root:lang(zh-TW) {
--font-sans:
'Fluxer Sans', 'Fluxer Sans TC', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari',
'Fluxer Sans Thai Looped', 'Fluxer Sans SC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
BlinkMacSystemFont, 'PingFang TC', 'Microsoft JhengHei', var(--font-fallback-sans), sans-serif;
}
:root:lang(zh-HK),
:root:lang(zh-Hant-HK) {
--font-sans:
'Fluxer Sans', 'Fluxer Sans TC', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari',
'Fluxer Sans Thai Looped', 'Fluxer Sans SC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
BlinkMacSystemFont, 'PingFang HK', 'Microsoft JhengHei', var(--font-fallback-sans), sans-serif;
}
:root:lang(ar) {
--font-sans:
'Fluxer Sans', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari', 'Fluxer Sans Thai Looped',
'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
BlinkMacSystemFont, 'Segoe UI', 'Geeza Pro', 'Arabic Typesetting', var(--font-fallback-sans), sans-serif;
}
:root:lang(he) {
--font-sans:
'Fluxer Sans', 'Fluxer Sans Hebrew', 'Fluxer Sans Arabic', 'Fluxer Sans Devanagari', 'Fluxer Sans Thai Looped',
'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
BlinkMacSystemFont, 'Segoe UI', 'Arial Hebrew', var(--font-fallback-sans), sans-serif;
}
:root:lang(hi),
:root:lang(mr),
:root:lang(ne),
:root:lang(sa) {
--font-sans:
'Fluxer Sans', 'Fluxer Sans Devanagari', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Thai Looped',
'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
BlinkMacSystemFont, 'Nirmala UI', 'Noto Sans Devanagari', var(--font-fallback-sans), sans-serif;
}
:root:lang(th) {
--font-sans:
'Fluxer Sans', 'Fluxer Sans Thai Looped', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari',
'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
BlinkMacSystemFont, 'Leelawadee UI', 'Thonburi', var(--font-fallback-sans), sans-serif;
}
@@ -0,0 +1,96 @@
Copyright 2012-2025 The Noto Project Authors (https://github.com/notofonts)
This Font Software is a subset of Google Noto Sans faces, self-hosted for
guaranteed script coverage. The unmodified license follows.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the fonts.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

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