mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 12:12:25 +09:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
805acf4e5e | ||
|
|
9f099a9127 | ||
|
|
4d15c39cd7 | ||
|
|
827451d12d | ||
|
|
03603662c6 | ||
|
|
34eb10cd88 | ||
|
|
82941c08c9 | ||
|
|
8d21c97d08 | ||
|
|
71a56f590d | ||
|
|
38297c4fe7 | ||
|
|
cf7ec06d85 | ||
|
|
f2ea10f951 | ||
|
|
bbd93df239 | ||
|
|
9a6ab93e01 | ||
|
|
38eed7cce6 | ||
|
|
79064c3399 | ||
|
|
0496b2f530 | ||
|
|
9d0be1ebd1 | ||
|
|
9ad026b8ce | ||
|
|
14de5971d5 | ||
|
|
5474be3efa | ||
|
|
9a54bbba2d | ||
|
|
5a0110ccc8 | ||
|
|
d032d577bf | ||
|
|
243954c9c5 | ||
|
|
ba1be73389 | ||
|
|
af3ad02962 | ||
|
|
53ddca725e | ||
|
|
094fb0d1c8 | ||
|
|
dc230926a4 | ||
|
|
026ace6747 | ||
|
|
374db9ed2b | ||
|
|
5ee59c4675 | ||
|
|
33605171a8 | ||
|
|
89fac5b088 | ||
|
|
4a34b942b7 | ||
|
|
fc3065ebe4 | ||
|
|
23493b4ac2 | ||
|
|
13344096b7 | ||
|
|
a800430997 | ||
|
|
509562e6da | ||
|
|
88d85919f1 | ||
|
|
154e223284 | ||
|
|
58732f7770 |
@@ -524,6 +524,7 @@ jobs:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
@@ -553,11 +554,29 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_payload
|
||||
|
||||
- name: Prepare GitHub release assets
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_release_assets
|
||||
|
||||
- name: Publish GitHub release descriptor
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_descriptor
|
||||
|
||||
- name: Upload payload to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_payload
|
||||
|
||||
- name: Upload GitHub release asset handoff
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_release_assets
|
||||
|
||||
- name: Build summary
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
@@ -577,9 +596,17 @@ jobs:
|
||||
- upload
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
@@ -590,6 +617,12 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
|
||||
- name: Download GitHub release assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step download_release_assets
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
@@ -614,8 +647,16 @@ jobs:
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
--asset-dir release_assets
|
||||
)
|
||||
if [[ "${CHANNEL}" == "canary" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
||||
|
||||
- name: Publish GitHub release readiness marker
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_marker
|
||||
|
||||
@@ -55,6 +55,7 @@ FLUXER_EMAIL_ENABLED=false
|
||||
FLUXER_EMAIL_PROVIDER=none
|
||||
FLUXER_EMAIL_FROM_EMAIL=[email protected]
|
||||
FLUXER_EMAIL_FROM_NAME=Fluxer
|
||||
FLUXER_EMAIL_APP_BASE_URL=
|
||||
FLUXER_EMAIL_SMTP_HOST=
|
||||
FLUXER_EMAIL_SMTP_PORT=587
|
||||
FLUXER_EMAIL_SMTP_USERNAME=
|
||||
|
||||
@@ -57,6 +57,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-none}
|
||||
FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost}
|
||||
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-Fluxer}
|
||||
FLUXER_EMAIL_APP_BASE_URL: ${FLUXER_EMAIL_APP_BASE_URL:-}
|
||||
FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-}
|
||||
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-587}
|
||||
FLUXER_EMAIL_SMTP_USERNAME: ${FLUXER_EMAIL_SMTP_USERNAME:-}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -23,6 +23,7 @@ type CacheEntry = {
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const CACHE_MAX_ENTRIES = 10_000;
|
||||
const geoipCache = new Map<string, CacheEntry>();
|
||||
|
||||
let maxmindReader: Reader<CityResponse> | null = null;
|
||||
@@ -85,6 +86,32 @@ function isAsciiUpperAlpha2(value: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function getCachedGeoipResult(cacheKey: string, normalizedIp: string): GeoipResult | null {
|
||||
const cached = geoipCache.get(cacheKey);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() >= cached.expiresAt) {
|
||||
geoipCache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
geoipCache.delete(cacheKey);
|
||||
geoipCache.set(cacheKey, cached);
|
||||
return {...cached.result, normalizedIp};
|
||||
}
|
||||
|
||||
function setCachedGeoipResult(cacheKey: string, result: GeoipResult): void {
|
||||
geoipCache.delete(cacheKey);
|
||||
if (geoipCache.size >= CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = geoipCache.keys().next().value;
|
||||
if (oldestKey === undefined) {
|
||||
throw new Error('GeoIP cache reached capacity without an entry to evict');
|
||||
}
|
||||
geoipCache.delete(oldestKey);
|
||||
}
|
||||
geoipCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
|
||||
}
|
||||
|
||||
async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult> {
|
||||
try {
|
||||
const reader = await ensureReader(dbPath);
|
||||
@@ -108,14 +135,13 @@ async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult
|
||||
}
|
||||
|
||||
async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult> {
|
||||
const now = Date.now();
|
||||
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
|
||||
const cached = geoipCache.get(cacheKey);
|
||||
if (cached && now < cached.expiresAt) {
|
||||
return {...cached.result, normalizedIp: clean};
|
||||
const cached = getCachedGeoipResult(cacheKey, clean);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const result = await lookupMaxmind(clean, dbPath);
|
||||
geoipCache.set(cacheKey, {result, expiresAt: now + CACHE_TTL_MS});
|
||||
setCachedGeoipResult(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -19,6 +19,26 @@ function trimTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function resolveEmailAppBaseUrl(master: MasterConfig): string {
|
||||
const configuredAppBaseUrl = master.integrations.email.app_base_url.trim();
|
||||
if (!configuredAppBaseUrl) return trimTrailingSlash(master.endpoints.app);
|
||||
try {
|
||||
const appBaseUrl = new URL(configuredAppBaseUrl);
|
||||
if (
|
||||
(appBaseUrl.protocol !== 'http:' && appBaseUrl.protocol !== 'https:') ||
|
||||
appBaseUrl.username ||
|
||||
appBaseUrl.password ||
|
||||
appBaseUrl.search ||
|
||||
appBaseUrl.hash
|
||||
) {
|
||||
throw new Error(`Invalid email app base URL: ${configuredAppBaseUrl}`);
|
||||
}
|
||||
return trimTrailingSlash(appBaseUrl.toString());
|
||||
} catch {
|
||||
throw new Error(`Invalid email app base URL: ${configuredAppBaseUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGatewayInternalUrl(master: MasterConfig): string {
|
||||
const configuredInternalGateway = (
|
||||
master.internal as {
|
||||
@@ -65,6 +85,18 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
|
||||
return Array.from(normalized);
|
||||
}
|
||||
|
||||
function normalizeCountryCodes(values: Array<string>, configName: string): ReadonlySet<string> {
|
||||
const normalized = new Set<string>();
|
||||
for (const value of values) {
|
||||
const countryCode = value.trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/u.test(countryCode)) {
|
||||
throw new Error(`${configName} contains an invalid ISO 3166-1 alpha-2 country code: ${value}`);
|
||||
}
|
||||
normalized.add(countryCode);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapPushProviderApps(
|
||||
apps:
|
||||
| Array<{
|
||||
@@ -122,6 +154,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
nodeEnv: master.env === 'test' ? 'development' : master.env,
|
||||
port: master.services.api.port,
|
||||
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
|
||||
desktopGitHubRedirectCountries: normalizeCountryCodes(
|
||||
master.services.api.desktop_github_redirect_countries,
|
||||
'FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES',
|
||||
),
|
||||
cassandra: {
|
||||
hosts: cassandraSource?.hosts.join(',') ?? '',
|
||||
port: cassandraSource?.port ?? 9042,
|
||||
@@ -254,6 +290,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
webhookSecret: master.integrations.email.webhook_secret ?? undefined,
|
||||
fromEmail: master.integrations.email.from_email,
|
||||
fromName: master.integrations.email.from_name,
|
||||
appBaseUrl: resolveEmailAppBaseUrl(master),
|
||||
smtp: master.integrations.email.smtp
|
||||
? {
|
||||
host: master.integrations.email.smtp.host,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -91,6 +91,7 @@ export async function createAuthSession(
|
||||
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
|
||||
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
|
||||
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
|
||||
user = await AuthUtility.handleBanStatus(ctx, user);
|
||||
const now = new Date();
|
||||
const token = await AuthUtility.generateAuthToken(ctx);
|
||||
let clientCountry: string | null = null;
|
||||
|
||||
@@ -225,7 +225,7 @@ function resolveEmailVerified({
|
||||
if (values.includes(false)) {
|
||||
return false;
|
||||
}
|
||||
return values.length === 0 || values.includes(true);
|
||||
return values.length > 0 && values.includes(true);
|
||||
}
|
||||
|
||||
function isJsonWebKeySet(value: unknown): value is JSONWebKeySet {
|
||||
|
||||
@@ -233,11 +233,14 @@ export class AttachmentUploadService {
|
||||
const parts = await Promise.all(
|
||||
Array.from({length: partCount}, async (_, index) => {
|
||||
const partNumber = index + 1;
|
||||
const partContentLength =
|
||||
partNumber < partCount ? partSize : attachment.file_size - partSize * (partCount - 1);
|
||||
const presigned_upload_url = await this.storageService.getPresignedUploadPartURL({
|
||||
bucket,
|
||||
key: uploadKey,
|
||||
uploadId,
|
||||
partNumber,
|
||||
contentLength: partContentLength,
|
||||
});
|
||||
const upload_url = applyUploadRelayDecision({
|
||||
presignedUrl: presigned_upload_url,
|
||||
@@ -246,7 +249,7 @@ export class AttachmentUploadService {
|
||||
relayDecision: uploadRelayDecision,
|
||||
uploadId,
|
||||
partNumber,
|
||||
maxBytes: partSize,
|
||||
maxBytes: partContentLength,
|
||||
});
|
||||
return {part_number: partNumber, upload_url};
|
||||
}),
|
||||
@@ -275,7 +278,7 @@ export class AttachmentUploadService {
|
||||
if (!Config.presignedAttachmentUploadsEnabled) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
await this.getUploadPermissionAndLimit({userId, channelId});
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId});
|
||||
const bucket = Config.s3.buckets.uploads;
|
||||
return Promise.all(
|
||||
uploads.map(async ({upload_filename, upload_id}, index) => {
|
||||
@@ -305,6 +308,13 @@ export class AttachmentUploadService {
|
||||
.catch(() => undefined);
|
||||
throw InputValidationError.fromCode('parts', ValidationErrorCodes.NO_UPLOADED_PARTS_TO_FINALIZE);
|
||||
}
|
||||
const totalUploadedBytes = parts.reduce((sum, part) => sum + (part.size ?? 0), 0);
|
||||
if (totalUploadedBytes > maxFileSize) {
|
||||
await this.storageService
|
||||
.abortMultipartUpload({bucket, key: upload_filename, uploadId: upload_id})
|
||||
.catch(() => undefined);
|
||||
throw new FileSizeTooLargeError(maxFileSize);
|
||||
}
|
||||
try {
|
||||
await runAttachmentStorageOperation(() =>
|
||||
this.storageService.completeMultipartUpload({
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
|
||||
import type {ChannelID, GuildID, UserID} from '../../BrandedTypes';
|
||||
import {SYSTEM_USER_ID} from '../../constants/Core';
|
||||
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
|
||||
import {createGuildMfaEnforcer} from '../../guild/services/GuildMfaEnforcement';
|
||||
import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import type {Channel} from '../../models/Channel';
|
||||
import type {GuildMember} from '../../models/GuildMember';
|
||||
@@ -173,8 +174,15 @@ export abstract class BaseChannelAuthService {
|
||||
userId,
|
||||
memberData: guildMemberResult.memberData!,
|
||||
});
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({
|
||||
userRepository: this.userRepository,
|
||||
guildData: guildDataResult!,
|
||||
userId,
|
||||
});
|
||||
const hasPermission = async (permission: bigint): Promise<boolean> => {
|
||||
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
|
||||
const allowed = await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
|
||||
if (allowed) enforceGuildMfa(permission);
|
||||
return allowed;
|
||||
};
|
||||
const checkPermission = async (permission: bigint): Promise<void> => {
|
||||
const allowed = await hasPermission(permission);
|
||||
|
||||
@@ -40,9 +40,8 @@ export class DMPermissionValidator {
|
||||
if (isBugHunterBotUser(senderUser)) {
|
||||
return;
|
||||
}
|
||||
if (!senderUser.isBot && (senderUser.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER) {
|
||||
return;
|
||||
}
|
||||
const isShadowbannedSpammer =
|
||||
!senderUser.isBot && (senderUser.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER;
|
||||
const [senderBlockedTarget, targetBlockedSender, areFriends, targetSettings, senderSettings] = await Promise.all([
|
||||
this.deps.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.BLOCKED),
|
||||
this.deps.userRepository.getRelationship(recipientId, senderId, RelationshipTypes.BLOCKED),
|
||||
@@ -53,6 +52,9 @@ export class DMPermissionValidator {
|
||||
if (senderBlockedTarget || targetBlockedSender) {
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
if (isShadowbannedSpammer) {
|
||||
return;
|
||||
}
|
||||
if (areFriends) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {assertGuildMemberCanCommunicate} from '../../utils/GuildCommunicationUtils';
|
||||
import type {IChannelRepository} from '../IChannelRepository';
|
||||
import {MessageInteractionAuthService} from './interaction/MessageInteractionAuthService';
|
||||
import {MessagePinAuthService} from './interaction/MessagePinAuthService';
|
||||
import {MessagePinService} from './interaction/MessagePinService';
|
||||
import {MessageReactionService} from './interaction/MessageReactionService';
|
||||
import {MessageReadStateService} from './interaction/MessageReadStateService';
|
||||
@@ -25,6 +26,7 @@ import type {MessagePersistenceService} from './message/MessagePersistenceServic
|
||||
|
||||
export class MessageInteractionService {
|
||||
readonly authService: MessageInteractionAuthService;
|
||||
private pinAuthService: MessagePinAuthService;
|
||||
private readStateService: MessageReadStateService;
|
||||
private pinService: MessagePinService;
|
||||
private reactionService: MessageReactionService;
|
||||
@@ -45,6 +47,12 @@ export class MessageInteractionService {
|
||||
guildRepository,
|
||||
gatewayService,
|
||||
);
|
||||
this.pinAuthService = new MessagePinAuthService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
gatewayService,
|
||||
);
|
||||
this.readStateService = new MessageReadStateService(gatewayService);
|
||||
this.pinService = new MessagePinService(
|
||||
gatewayService,
|
||||
@@ -85,7 +93,7 @@ export class MessageInteractionService {
|
||||
items: Array<ChannelPinResponse>;
|
||||
has_more: boolean;
|
||||
}> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
const authChannel = await this.pinAuthService.getChannelAuthenticated({userId, channelId});
|
||||
return this.pinService.getChannelPins({authChannel, userId, requestCache, beforeTimestamp, limit});
|
||||
}
|
||||
|
||||
|
||||
@@ -186,12 +186,7 @@ export class ChannelOperationsService {
|
||||
let permissionOverwrites = channel.permissionOverwrites;
|
||||
if (data.permission_overwrites !== undefined) {
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
await checkPermission(Permissions.MANAGE_ROLES);
|
||||
const isOwner = guild.owner_id === userId.toString();
|
||||
const channelPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId,
|
||||
@@ -205,6 +200,17 @@ export class ChannelOperationsService {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
const nextDeny = new Map<RoleID | UserID, bigint>();
|
||||
for (const overwrite of data.permission_overwrites ?? []) {
|
||||
const targetKey = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
|
||||
nextDeny.set(targetKey, (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS);
|
||||
}
|
||||
for (const [targetId, existing] of previousPermissionOverwrites ?? []) {
|
||||
const removedDeny = existing.deny & ~(nextDeny.get(targetId) ?? 0n);
|
||||
if ((removedDeny & ~channelPermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
}
|
||||
permissionOverwrites = new Map();
|
||||
for (const overwrite of data.permission_overwrites ?? []) {
|
||||
@@ -590,6 +596,7 @@ export class ChannelOperationsService {
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
channelId: channel.id,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
@@ -615,6 +622,8 @@ export class ChannelOperationsService {
|
||||
const sanitizedDeny = protectedBits.deny;
|
||||
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
|
||||
if (!hasAdministrator && (sanitizedAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny;
|
||||
if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
const previousPermissionOverwrites = channel.permissionOverwrites;
|
||||
const overwrites = new Map(channel.permissionOverwrites ?? []);
|
||||
overwrites.set(
|
||||
@@ -697,6 +706,7 @@ export class ChannelOperationsService {
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
channelId: channel.id,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
@@ -705,6 +715,15 @@ export class ChannelOperationsService {
|
||||
const removedRole = overwrites.get(createRoleID(params.overwriteId));
|
||||
const removedUser = overwrites.get(createUserID(params.overwriteId));
|
||||
const removed = removedRole ?? removedUser;
|
||||
if (removed) {
|
||||
const userPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
channelId: channel.id,
|
||||
});
|
||||
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
|
||||
if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
}
|
||||
overwrites.delete(createRoleID(params.overwriteId));
|
||||
overwrites.delete(createUserID(params.overwriteId));
|
||||
const updated = await this.channelRepository.channelData.upsert({
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
|
||||
import type {MessageID, UserID} from '../../../BrandedTypes';
|
||||
import {Config} from '../../../Config';
|
||||
import type {IPurgeQueue} from '../../../infrastructure/BunnyPurgeQueue';
|
||||
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||
import type {IStorageService} from '../../../infrastructure/IStorageService';
|
||||
@@ -13,7 +12,7 @@ import type {Message} from '../../../models/Message';
|
||||
import {mapChannelToResponse} from '../../ChannelMappers';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import {dispatchMessageCreateBroadcast} from '../message/MessageGatewayDispatch';
|
||||
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '../message/MessageHelpers';
|
||||
import {purgeMessageAttachments} from '../message/MessageHelpers';
|
||||
|
||||
export class ChannelUtilsService {
|
||||
constructor(
|
||||
@@ -44,20 +43,7 @@ export class ChannelUtilsService {
|
||||
}
|
||||
|
||||
private async purgeMessageAttachments(message: Message): Promise<void> {
|
||||
const cdnUrls: Array<string> = [];
|
||||
await Promise.all(
|
||||
message.attachments.map(async (attachment) => {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
|
||||
if (Config.bunny.purgeEnabled) {
|
||||
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
|
||||
cdnUrls.push(cdnUrl);
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
|
||||
await this.purgeQueue.addUrls(cdnUrls);
|
||||
}
|
||||
await purgeMessageAttachments(message, this.storageService, this.purgeQueue);
|
||||
}
|
||||
|
||||
async dispatchChannelUpdate({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {BaseChannelAuthService, type ChannelAuthOptions} from '../BaseChannelAuthService';
|
||||
|
||||
export class MessagePinAuthService extends BaseChannelAuthService {
|
||||
protected readonly options: ChannelAuthOptions = {
|
||||
errorOnMissingGuild: 'unknown_channel',
|
||||
validateNsfw: true,
|
||||
};
|
||||
}
|
||||
@@ -116,6 +116,16 @@ export class AttachmentProcessingService {
|
||||
);
|
||||
const hasVirusDetected = results.some((result) => result.hasVirusDetected);
|
||||
if (hasVirusDetected) {
|
||||
await Promise.all(
|
||||
results.map(async (result) => {
|
||||
if (result.sourceLocalPath) {
|
||||
await fs.promises.unlink(result.sourceLocalPath).catch(() => undefined);
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const result of results) {
|
||||
this.deleteUploadObject(result.copyOperation.sourceBucket, result.copyOperation.sourceKey);
|
||||
}
|
||||
return {attachments: [], hasVirusDetected: true};
|
||||
}
|
||||
const copyResults = await mapWithConcurrency(results, ATTACHMENT_PROCESSING_CONCURRENCY, (result) =>
|
||||
|
||||
@@ -225,14 +225,14 @@ export class MessageDeleteService {
|
||||
async deleteUserMessagesInGuild({
|
||||
userId,
|
||||
guildId,
|
||||
days,
|
||||
seconds,
|
||||
}: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
days: number;
|
||||
seconds: number;
|
||||
}): Promise<void> {
|
||||
const channels = await this.deps.channelRepository.channelData.listGuildChannels(guildId);
|
||||
const cutoffTimestamp = Date.now() - days * ms('1 day');
|
||||
const cutoffTimestamp = Date.now() - seconds * ms('1 second');
|
||||
const cutoffSnowflake = createMessageID(createSnowflakeFromTimestamp(cutoffTimestamp));
|
||||
await Promise.all(
|
||||
channels.map(async (channel: Channel) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {LimitConfigService} from '../../../limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '../../../limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder';
|
||||
import {Attachment} from '../../../models/Attachment';
|
||||
import type {Embed} from '../../../models/Embed';
|
||||
import type {Message} from '../../../models/Message';
|
||||
import {MessageSnapshot as MessageSnapshotModel} from '../../../models/MessageSnapshot';
|
||||
import type {User} from '../../../models/User';
|
||||
@@ -307,22 +308,60 @@ export async function createMessageSnapshotsForForward(
|
||||
return [new MessageSnapshotModel(snapshotData)];
|
||||
}
|
||||
|
||||
function collectEmbedReferencedAttachmentCdnKeys(message: Message): Array<string> {
|
||||
const mediaPrefix = `${Config.endpoints.media}/`;
|
||||
const keys = new Set<string>();
|
||||
const consider = (url: string | null | undefined): void => {
|
||||
if (!url || !url.startsWith(mediaPrefix)) {
|
||||
return;
|
||||
}
|
||||
const key = url.slice(mediaPrefix.length);
|
||||
if (key.startsWith('attachments/')) {
|
||||
keys.add(key);
|
||||
}
|
||||
};
|
||||
const scanEmbeds = (embeds: Array<Embed>): void => {
|
||||
for (const embed of embeds) {
|
||||
consider(embed.image?.url);
|
||||
consider(embed.thumbnail?.url);
|
||||
consider(embed.video?.url);
|
||||
consider(embed.audio?.url);
|
||||
}
|
||||
};
|
||||
scanEmbeds(message.embeds);
|
||||
for (const snapshot of message.messageSnapshots) {
|
||||
scanEmbeds(snapshot.embeds);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export async function purgeMessageAttachments(
|
||||
message: Message,
|
||||
storageService: IStorageService,
|
||||
purgeQueue: IPurgeQueue,
|
||||
): Promise<void> {
|
||||
const cdnKeys = new Set<string>();
|
||||
const cdnUrls: Array<string> = [];
|
||||
await Promise.all(
|
||||
message.attachments.map(async (attachment) => {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
await storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
|
||||
if (Config.bunny.purgeEnabled) {
|
||||
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
|
||||
cdnUrls.push(cdnUrl);
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const attachment of collectMessageAttachments(message)) {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
if (cdnKeys.has(cdnKey)) {
|
||||
continue;
|
||||
}
|
||||
cdnKeys.add(cdnKey);
|
||||
if (Config.bunny.purgeEnabled) {
|
||||
cdnUrls.push(makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename));
|
||||
}
|
||||
}
|
||||
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message)) {
|
||||
if (cdnKeys.has(embedKey)) {
|
||||
continue;
|
||||
}
|
||||
cdnKeys.add(embedKey);
|
||||
if (Config.bunny.purgeEnabled) {
|
||||
cdnUrls.push(`${Config.endpoints.media}/${embedKey}`);
|
||||
}
|
||||
}
|
||||
await Promise.all([...cdnKeys].map((cdnKey) => storageService.deleteObject(Config.s3.buckets.cdn, cdnKey)));
|
||||
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
|
||||
await purgeQueue.addUrls(cdnUrls);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {IFavoriteMemeRepository} from '../../../favorite_meme/IFavoriteMeme
|
||||
import type {IMediaService} from '../../../infrastructure/IMediaService';
|
||||
import type {ISnowflakeService} from '../../../infrastructure/ISnowflakeService';
|
||||
import type {IStorageService} from '../../../infrastructure/IStorageService';
|
||||
import {Logger} from '../../../Logger';
|
||||
import type {FavoriteMeme} from '../../../models/FavoriteMeme';
|
||||
import type {Message} from '../../../models/Message';
|
||||
import type {User} from '../../../models/User';
|
||||
@@ -90,8 +91,21 @@ export class MessageOperationsHelpers {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const needsAnimationProbe =
|
||||
!favoriteMeme.isGifv &&
|
||||
favoriteMeme.contentType !== 'image/gif' &&
|
||||
favoriteMeme.contentType !== 'image/apng' &&
|
||||
ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType);
|
||||
const metadata =
|
||||
needsAnimationProbe || favoriteMeme.placeholder == null
|
||||
? await this.probeFavoriteMemeMetadata(favoriteMeme)
|
||||
: null;
|
||||
const placeholder = favoriteMeme.placeholder ?? metadata?.placeholder ?? null;
|
||||
if (placeholder != null && favoriteMeme.placeholder == null) {
|
||||
await this.repairFavoriteMemePlaceholder(favoriteMeme, placeholder);
|
||||
}
|
||||
let flags = 0;
|
||||
if (await this.isFavoriteMemeAnimated(favoriteMeme)) {
|
||||
if (this.isFavoriteMemeAnimated(favoriteMeme, metadata)) {
|
||||
flags |= MessageAttachmentFlags.IS_ANIMATED;
|
||||
}
|
||||
return {
|
||||
@@ -104,7 +118,7 @@ export class MessageOperationsHelpers {
|
||||
height: favoriteMeme.height,
|
||||
content_type: favoriteMeme.contentType,
|
||||
content_hash: favoriteMeme.contentHash,
|
||||
placeholder: null,
|
||||
placeholder,
|
||||
flags,
|
||||
duration: favoriteMeme.duration,
|
||||
nsfw: null,
|
||||
@@ -112,20 +126,31 @@ export class MessageOperationsHelpers {
|
||||
};
|
||||
}
|
||||
|
||||
private async isFavoriteMemeAnimated(favoriteMeme: FavoriteMeme): Promise<boolean> {
|
||||
if (favoriteMeme.isGifv) return true;
|
||||
if (favoriteMeme.contentType === 'image/gif' || favoriteMeme.contentType === 'image/apng') return true;
|
||||
if (!ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType)) return false;
|
||||
private async probeFavoriteMemeMetadata(favoriteMeme: FavoriteMeme) {
|
||||
try {
|
||||
const metadata = await this.deps.mediaService.getMetadata({
|
||||
return await this.deps.mediaService.getMetadata({
|
||||
type: 's3',
|
||||
bucket: Config.s3.buckets.cdn,
|
||||
key: favoriteMeme.storageKey,
|
||||
nsfw: 'allow',
|
||||
});
|
||||
return metadata?.animated === true;
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async repairFavoriteMemePlaceholder(favoriteMeme: FavoriteMeme, placeholder: string): Promise<void> {
|
||||
try {
|
||||
await this.deps.favoriteMemeRepository.updatePlaceholder(favoriteMeme.userId, favoriteMeme.id, placeholder);
|
||||
} catch (error) {
|
||||
Logger.warn({error, memeId: favoriteMeme.id.toString()}, 'Failed to backfill favorite meme placeholder');
|
||||
}
|
||||
}
|
||||
|
||||
private isFavoriteMemeAnimated(favoriteMeme: FavoriteMeme, metadata: {animated?: boolean | null} | null): boolean {
|
||||
if (favoriteMeme.isGifv) return true;
|
||||
if (favoriteMeme.contentType === 'image/gif' || favoriteMeme.contentType === 'image/apng') return true;
|
||||
if (!ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType)) return false;
|
||||
return metadata?.animated === true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1191,7 +1191,7 @@ export class MessageSendService {
|
||||
await this.deps.mentionService.handleMentionTasks({
|
||||
guildId: channel.guildId,
|
||||
message,
|
||||
authorId: createUserID(0n),
|
||||
authorId: createUserID(BigInt(webhook.id)),
|
||||
mentionHere: mentionData?.mentionHere ?? false,
|
||||
});
|
||||
await this.deps.dispatchService.dispatchMessageCreate({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface APIConfig {
|
||||
nodeEnv: 'development' | 'production';
|
||||
port: number;
|
||||
ipBanExemptIps: Array<string>;
|
||||
desktopGitHubRedirectCountries: ReadonlySet<string>;
|
||||
cassandra: {
|
||||
hosts: string;
|
||||
port: number;
|
||||
@@ -158,6 +159,7 @@ export interface APIConfig {
|
||||
webhookSecret?: string;
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
appBaseUrl: string;
|
||||
smtp?: {
|
||||
host: string;
|
||||
port: number;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {DesktopArch, DesktopChannel, DesktopPlatform} from '@fluxer/schema/src/domains/download/DownloadSchemas';
|
||||
import {isJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const MIN_RELEASE_ROUTE_COUNT = 28;
|
||||
const MAX_RELEASE_ROUTE_COUNT = 128;
|
||||
const MIN_RELEASE_ASSET_COUNT = 24;
|
||||
|
||||
interface DesktopReleaseAsset {
|
||||
storage_key: string;
|
||||
release_asset: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface DesktopReleaseDescriptor {
|
||||
schema_version: 1;
|
||||
channel: DesktopChannel;
|
||||
version: string;
|
||||
release_tag: string;
|
||||
source_sha: string;
|
||||
assets: Array<DesktopReleaseAsset>;
|
||||
}
|
||||
|
||||
interface DesktopReleaseReadiness {
|
||||
schema_version: 1;
|
||||
channel: DesktopChannel;
|
||||
version: string;
|
||||
release_tag: string;
|
||||
source_sha: string;
|
||||
descriptor_sha256: string;
|
||||
}
|
||||
|
||||
interface DesktopArtifactScope {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
}
|
||||
|
||||
export function parseDesktopArtifactScope(key: string): DesktopArtifactScope | null {
|
||||
const segments = key.split('/');
|
||||
if (segments.length !== 5 || segments[0] !== DESKTOP_BUCKET_PREFIX || segments[4].length === 0) {
|
||||
return null;
|
||||
}
|
||||
const [, channel, plat, arch] = segments;
|
||||
if (
|
||||
(channel !== 'stable' && channel !== 'canary') ||
|
||||
(plat !== 'win32' && plat !== 'darwin' && plat !== 'linux') ||
|
||||
(arch !== 'x64' && arch !== 'arm64')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {channel, plat, arch};
|
||||
}
|
||||
|
||||
function parseDesktopReleaseAsset(value: unknown): DesktopReleaseAsset | null {
|
||||
if (
|
||||
!isJsonRecord(value) ||
|
||||
typeof value.storage_key !== 'string' ||
|
||||
typeof value.release_asset !== 'string' ||
|
||||
typeof value.sha256 !== 'string' ||
|
||||
typeof value.size !== 'number'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!/^desktop\/(?:stable|canary)\/(?:win32|darwin|linux)\/(?:x64|arm64)\/[A-Za-z0-9._-]+$/u.test(value.storage_key) ||
|
||||
!/^[A-Za-z0-9._-]+$/u.test(value.release_asset) ||
|
||||
!/^[a-f0-9]{64}$/u.test(value.sha256) ||
|
||||
!Number.isSafeInteger(value.size) ||
|
||||
value.size <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
storage_key: value.storage_key,
|
||||
release_asset: value.release_asset,
|
||||
sha256: value.sha256,
|
||||
size: value.size,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDesktopReleaseDescriptor(value: unknown): DesktopReleaseDescriptor | null {
|
||||
if (
|
||||
!isJsonRecord(value) ||
|
||||
value.schema_version !== 1 ||
|
||||
(value.channel !== 'stable' && value.channel !== 'canary') ||
|
||||
typeof value.version !== 'string' ||
|
||||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
|
||||
typeof value.release_tag !== 'string' ||
|
||||
typeof value.source_sha !== 'string' ||
|
||||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
|
||||
!Array.isArray(value.assets) ||
|
||||
value.assets.length < MIN_RELEASE_ROUTE_COUNT ||
|
||||
value.assets.length > MAX_RELEASE_ROUTE_COUNT
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const expectedTag = `fluxer-desktop-${value.channel}@${value.version}`;
|
||||
const expectedStoragePrefix = `desktop/${value.channel}/`;
|
||||
const expectedReleasePrefix = `${value.channel === 'canary' ? 'Fluxer-Canary' : 'Fluxer'}-${value.version}-`;
|
||||
const descriptorName = `${expectedReleasePrefix}release-manifest.json`;
|
||||
if (value.release_tag !== expectedTag) {
|
||||
return null;
|
||||
}
|
||||
const storageKeys = new Set<string>();
|
||||
const routeCounts = new Map<string, number>();
|
||||
const releaseAssets = new Map<string, {sha256: string; size: number}>();
|
||||
const releaseAssetNames = new Map<string, string>([[descriptorName.toLowerCase(), descriptorName]]);
|
||||
const assets: Array<DesktopReleaseAsset> = [];
|
||||
for (const rawAsset of value.assets) {
|
||||
const asset = parseDesktopReleaseAsset(rawAsset);
|
||||
if (
|
||||
!asset ||
|
||||
!asset.storage_key.startsWith(expectedStoragePrefix) ||
|
||||
!asset.release_asset.startsWith(expectedReleasePrefix) ||
|
||||
storageKeys.has(asset.storage_key)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
storageKeys.add(asset.storage_key);
|
||||
const [, , platform, arch, filename] = asset.storage_key.split('/');
|
||||
const platformToken = platform === 'win32' ? 'win' : platform === 'darwin' ? 'mac' : 'linux';
|
||||
const releaseFilename =
|
||||
platform === 'darwin' && filename.toLowerCase() === 'releases.json' ? 'releases.json' : filename;
|
||||
const expectedReleaseAsset = filename.startsWith(expectedReleasePrefix)
|
||||
? filename
|
||||
: `${expectedReleasePrefix}${platformToken}-${arch}-${releaseFilename}`;
|
||||
if (
|
||||
asset.release_asset !== expectedReleaseAsset ||
|
||||
asset.release_asset.toLowerCase() === descriptorName.toLowerCase()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const caseFoldedReleaseAsset = asset.release_asset.toLowerCase();
|
||||
const existingReleaseAssetName = releaseAssetNames.get(caseFoldedReleaseAsset);
|
||||
if (existingReleaseAssetName && existingReleaseAssetName !== asset.release_asset) {
|
||||
return null;
|
||||
}
|
||||
releaseAssetNames.set(caseFoldedReleaseAsset, asset.release_asset);
|
||||
const scope = `${platform}/${arch}`;
|
||||
routeCounts.set(scope, (routeCounts.get(scope) ?? 0) + 1);
|
||||
const existing = releaseAssets.get(asset.release_asset);
|
||||
if (existing && (existing.sha256 !== asset.sha256 || existing.size !== asset.size)) {
|
||||
return null;
|
||||
}
|
||||
releaseAssets.set(asset.release_asset, {sha256: asset.sha256, size: asset.size});
|
||||
assets.push(asset);
|
||||
}
|
||||
if (releaseAssets.size < MIN_RELEASE_ASSET_COUNT || releaseAssets.size > MAX_RELEASE_ROUTE_COUNT) {
|
||||
return null;
|
||||
}
|
||||
const expectedRouteCounts = new Map([
|
||||
['darwin/arm64', 4],
|
||||
['darwin/x64', 4],
|
||||
['linux/arm64', 4],
|
||||
['linux/x64', 4],
|
||||
['win32/arm64', 6],
|
||||
['win32/x64', 6],
|
||||
]);
|
||||
if (
|
||||
routeCounts.size !== expectedRouteCounts.size ||
|
||||
Array.from(expectedRouteCounts).some(([scope, count]) => (routeCounts.get(scope) ?? 0) < count)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
channel: value.channel,
|
||||
version: value.version,
|
||||
release_tag: value.release_tag,
|
||||
source_sha: value.source_sha,
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDesktopReleaseReadiness(value: unknown): DesktopReleaseReadiness | null {
|
||||
if (
|
||||
!isJsonRecord(value) ||
|
||||
value.schema_version !== 1 ||
|
||||
(value.channel !== 'stable' && value.channel !== 'canary') ||
|
||||
typeof value.version !== 'string' ||
|
||||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
|
||||
typeof value.release_tag !== 'string' ||
|
||||
typeof value.source_sha !== 'string' ||
|
||||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
|
||||
typeof value.descriptor_sha256 !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/u.test(value.descriptor_sha256)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
channel: value.channel,
|
||||
version: value.version,
|
||||
release_tag: value.release_tag,
|
||||
source_sha: value.source_sha,
|
||||
descriptor_sha256: value.descriptor_sha256,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {Config} from '../Config';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
import {Validator} from '../Validator';
|
||||
import {resolveArtifactRoute} from './DownloadRouting';
|
||||
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from './DownloadService';
|
||||
import {
|
||||
DESKTOP_REDIRECT_PREFIX,
|
||||
@@ -29,6 +30,17 @@ function artifactFilename(key: string, filenameOverride?: string): string {
|
||||
return filenameOverride ?? key.split('/').pop() ?? 'download';
|
||||
}
|
||||
|
||||
function artifactRedirectResponse(location: string, cacheControl = 'no-store'): Response {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: new Headers({
|
||||
Location: location,
|
||||
'Cache-Control': cacheControl,
|
||||
'Accept-Ranges': 'bytes',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function setCommonArtifactHeaders(
|
||||
headers: Headers,
|
||||
key: string,
|
||||
@@ -87,8 +99,12 @@ async function streamArtifactResponse(
|
||||
cacheControl: string,
|
||||
filenameOverride?: string,
|
||||
): Promise<Response> {
|
||||
const route = await resolveArtifactRoute({request: ctx.req.raw, downloadService, key, cacheControl});
|
||||
if (route.kind === 'redirect') {
|
||||
return artifactRedirectResponse(route.location, route.cacheControl);
|
||||
}
|
||||
if (ctx.req.method === 'HEAD') {
|
||||
return headArtifactResponse(ctx, downloadService, key, cacheControl, filenameOverride);
|
||||
return headArtifactResponse(ctx, downloadService, key, route.cacheControl, filenameOverride);
|
||||
}
|
||||
if (downloadService.isPresignedDownloadEnabled()) {
|
||||
const location = await downloadService.getPresignedDownloadRedirect({
|
||||
@@ -99,14 +115,7 @@ async function streamArtifactResponse(
|
||||
if (!location) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: new Headers({
|
||||
Location: location,
|
||||
'Cache-Control': 'no-store',
|
||||
'Accept-Ranges': 'bytes',
|
||||
}),
|
||||
});
|
||||
return artifactRedirectResponse(location);
|
||||
}
|
||||
const range = ctx.req.header('range') ?? undefined;
|
||||
let result: DownloadStreamResult | null;
|
||||
@@ -117,7 +126,7 @@ async function streamArtifactResponse(
|
||||
const headers = new Headers();
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
headers.set('Content-Range', `bytes */${error.totalSize}`);
|
||||
headers.set('Cache-Control', cacheControl);
|
||||
headers.set('Cache-Control', route.cacheControl);
|
||||
return new Response(null, {status: 416, headers});
|
||||
}
|
||||
throw error;
|
||||
@@ -129,7 +138,7 @@ async function streamArtifactResponse(
|
||||
setCommonArtifactHeaders(
|
||||
headers,
|
||||
key,
|
||||
cacheControl,
|
||||
route.cacheControl,
|
||||
filenameOverride,
|
||||
result.contentType,
|
||||
result.contentDisposition,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '../Config';
|
||||
import {Logger} from '../Logger';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {parseDesktopArtifactScope} from './DesktopReleaseContract';
|
||||
import type {DownloadService, GitHubDesktopReleaseResolution} from './DownloadService';
|
||||
|
||||
const COUNTRY_DEPENDENT_CACHE_CONTROL = 'private, no-store';
|
||||
|
||||
type ArtifactRoute =
|
||||
| {kind: 'storage'; cacheControl: string}
|
||||
| {kind: 'redirect'; cacheControl: string; location: string};
|
||||
|
||||
export async function resolveArtifactRoute(params: {
|
||||
request: Request;
|
||||
downloadService: DownloadService;
|
||||
key: string;
|
||||
cacheControl: string;
|
||||
}): Promise<ArtifactRoute> {
|
||||
if (Config.instance.selfHosted) {
|
||||
return {kind: 'storage', cacheControl: params.cacheControl};
|
||||
}
|
||||
if (Config.desktopGitHubRedirectCountries.size === 0) {
|
||||
return {kind: 'storage', cacheControl: params.cacheControl};
|
||||
}
|
||||
if (!parseDesktopArtifactScope(params.key)) {
|
||||
return {kind: 'storage', cacheControl: params.cacheControl};
|
||||
}
|
||||
const geoip = await lookupGeoip(params.request);
|
||||
const countryCode = geoip.countryCode?.trim().toUpperCase();
|
||||
if (!countryCode || !Config.desktopGitHubRedirectCountries.has(countryCode)) {
|
||||
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
|
||||
}
|
||||
let release: GitHubDesktopReleaseResolution;
|
||||
try {
|
||||
release = await params.downloadService.resolveGitHubDesktopRelease(params.key);
|
||||
} catch (error) {
|
||||
Logger.error({error, key: params.key}, 'Failed to resolve GitHub desktop download route');
|
||||
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
|
||||
}
|
||||
if (release.kind === 'not_current') {
|
||||
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
|
||||
}
|
||||
if (release.kind === 'ready') {
|
||||
return {
|
||||
kind: 'redirect',
|
||||
cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL,
|
||||
location: release.location,
|
||||
};
|
||||
}
|
||||
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {posix} from 'node:path';
|
||||
import {Readable} from 'node:stream';
|
||||
import {S3ServiceException} from '@aws-sdk/client-s3';
|
||||
@@ -12,6 +13,11 @@ import type {
|
||||
import {Config} from '../Config';
|
||||
import type {IStorageService} from '../infrastructure/IStorageService';
|
||||
import {isJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
|
||||
import {
|
||||
parseDesktopArtifactScope,
|
||||
parseDesktopReleaseDescriptor,
|
||||
parseDesktopReleaseReadiness,
|
||||
} from './DesktopReleaseContract';
|
||||
|
||||
export const DOWNLOAD_PREFIX = '/dl';
|
||||
export const DESKTOP_REDIRECT_PREFIX = `${DOWNLOAD_PREFIX}/desktop`;
|
||||
@@ -45,7 +51,10 @@ 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';
|
||||
|
||||
function desktopBucketPrefix(test?: boolean): string {
|
||||
return test ? DESKTOP_TEST_BUCKET_PREFIX : DESKTOP_BUCKET_PREFIX;
|
||||
@@ -176,9 +185,70 @@ interface ManifestFilenameResolutionParams extends LatestFilenameLookupParams {
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export type GitHubDesktopReleaseResolution =
|
||||
| {kind: 'not_current'}
|
||||
| {kind: 'awaiting_release'}
|
||||
| {kind: 'ready'; location: string};
|
||||
|
||||
export class DownloadService {
|
||||
constructor(private readonly storageService: IStorageService) {}
|
||||
|
||||
async resolveGitHubDesktopRelease(key: string): Promise<GitHubDesktopReleaseResolution> {
|
||||
const scope = parseDesktopArtifactScope(key);
|
||||
if (!scope) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const manifestKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${scope.plat}/${scope.arch}/manifest.json`;
|
||||
const manifest = await this.readOptionalJsonObjectFromStorage(manifestKey);
|
||||
if (
|
||||
!isDesktopManifest(manifest) ||
|
||||
manifest.channel !== scope.channel ||
|
||||
manifest.platform !== scope.plat ||
|
||||
manifest.arch !== scope.arch
|
||||
) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
|
||||
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
|
||||
if (descriptorText == null) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
|
||||
if (
|
||||
!descriptor ||
|
||||
descriptor.channel !== scope.channel ||
|
||||
descriptor.version !== manifest.version ||
|
||||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
|
||||
}
|
||||
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
|
||||
if (!releaseAsset) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
|
||||
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
|
||||
if (marker == null) {
|
||||
return {kind: 'awaiting_release'};
|
||||
}
|
||||
const readiness = parseDesktopReleaseReadiness(marker);
|
||||
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
|
||||
if (
|
||||
!readiness ||
|
||||
readiness.channel !== descriptor.channel ||
|
||||
readiness.version !== descriptor.version ||
|
||||
readiness.release_tag !== descriptor.release_tag ||
|
||||
readiness.source_sha !== descriptor.source_sha ||
|
||||
readiness.descriptor_sha256 !== descriptorSha256
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
|
||||
}
|
||||
return {
|
||||
kind: 'ready',
|
||||
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveLatestDesktopKey(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
@@ -659,6 +729,11 @@ export class DownloadService {
|
||||
}
|
||||
|
||||
private async readJsonObjectFromStorage(key: string): Promise<unknown | null> {
|
||||
const text = await this.readTextFromStorage(key);
|
||||
return text == null ? null : parseJsonUnknown(text);
|
||||
}
|
||||
|
||||
private async readTextFromStorage(key: string): Promise<string | null> {
|
||||
const streamResult = await this.storageService.streamObject({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key,
|
||||
@@ -667,8 +742,29 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const body = Readable.toWeb(streamResult.body);
|
||||
const text = await new Response(body as ReadableStream).text();
|
||||
return parseJsonUnknown(text);
|
||||
return new Response(body as ReadableStream).text();
|
||||
}
|
||||
|
||||
private async readOptionalJsonObjectFromStorage(key: string): Promise<unknown | null> {
|
||||
try {
|
||||
return await this.readJsonObjectFromStorage(key);
|
||||
} catch (error) {
|
||||
if (isStorageNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async readOptionalTextFromStorage(key: string): Promise<string | null> {
|
||||
try {
|
||||
return await this.readTextFromStorage(key);
|
||||
} catch (error) {
|
||||
if (isStorageNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private isValidSha256(value: string): boolean {
|
||||
@@ -774,9 +870,11 @@ export class DownloadService {
|
||||
const {ext, arch: archMap} = mapping;
|
||||
const filenames = new Set<string>();
|
||||
for (const archSuffix of this.getArchTokens(archMap[arch as 'x64' | 'arm64'])) {
|
||||
const modernFilename = this.buildModernArtifactFilename(channel, version, plat, archSuffix, ext);
|
||||
if (modernFilename) {
|
||||
filenames.add(modernFilename);
|
||||
for (const productName of this.getModernProductNames(channel)) {
|
||||
filenames.add(`${productName}-${version}-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`);
|
||||
if (format === 'portable') {
|
||||
filenames.add(`${productName}-${version}-portable-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`);
|
||||
}
|
||||
}
|
||||
if (format === 'setup') {
|
||||
filenames.add(`fluxer-${channel}-${version}-${archSuffix}-setup${ext}`);
|
||||
@@ -784,9 +882,6 @@ export class DownloadService {
|
||||
filenames.add(`fluxer-${version}-${archSuffix}-setup${ext}`);
|
||||
filenames.add(`Fluxer-${version}-${archSuffix}-Setup${ext}`);
|
||||
} else if (format === 'portable') {
|
||||
filenames.add(
|
||||
`${this.getModernProductName(channel)}-${version}-portable-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`,
|
||||
);
|
||||
filenames.add(`fluxer-${channel}-${version}-portable-${archSuffix}${ext}`);
|
||||
filenames.add(`Fluxer-${version}-portable-${archSuffix}${ext}`);
|
||||
} else {
|
||||
@@ -816,7 +911,6 @@ export class DownloadService {
|
||||
}
|
||||
const {ext, arch: archMap} = mapping;
|
||||
const escapedExt = this.escapeRegex(ext);
|
||||
const escapedModernFilenamePrefix = this.escapeRegex(this.getModernProductName(channel));
|
||||
const modernPlatformToken = MODERN_PLATFORM_TOKENS[plat];
|
||||
for (const archSuffix of this.getArchTokens(archMap[arch as 'x64' | 'arm64'])) {
|
||||
const patterns = [
|
||||
@@ -828,18 +922,23 @@ export class DownloadService {
|
||||
`^[Ff]luxer-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(archSuffix)}(?:-[Ss]etup)?${escapedExt}$`,
|
||||
'u',
|
||||
),
|
||||
new RegExp(
|
||||
`^${escapedModernFilenamePrefix}-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
|
||||
'iu',
|
||||
),
|
||||
];
|
||||
if (format === 'portable') {
|
||||
for (const productName of this.getModernProductNames(channel)) {
|
||||
const escapedProductName = this.escapeRegex(productName);
|
||||
patterns.push(
|
||||
new RegExp(
|
||||
`^${escapedModernFilenamePrefix}-(\\d+\\.\\d+\\.\\d+)-portable-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
|
||||
`^${escapedProductName}-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
|
||||
'iu',
|
||||
),
|
||||
);
|
||||
if (format === 'portable') {
|
||||
patterns.push(
|
||||
new RegExp(
|
||||
`^${escapedProductName}-(\\d+\\.\\d+\\.\\d+)-portable-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
|
||||
'iu',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const pattern of patterns) {
|
||||
const match = filename.match(pattern);
|
||||
@@ -852,18 +951,8 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private getModernProductName(channel: DesktopChannel): string {
|
||||
return channel === 'canary' ? 'Fluxer Canary' : 'Fluxer';
|
||||
}
|
||||
|
||||
private buildModernArtifactFilename(
|
||||
channel: DesktopChannel,
|
||||
version: string,
|
||||
plat: DesktopPlatform,
|
||||
archToken: string,
|
||||
ext: string,
|
||||
): string {
|
||||
return `${this.getModernProductName(channel)}-${version}-${MODERN_PLATFORM_TOKENS[plat]}-${archToken}${ext}`;
|
||||
private getModernProductNames(channel: DesktopChannel): Array<string> {
|
||||
return channel === 'canary' ? ['Fluxer-Canary', 'Fluxer Canary'] : ['Fluxer'];
|
||||
}
|
||||
|
||||
private getArchTokens(archToken: string | Array<string>): Array<string> {
|
||||
@@ -899,7 +988,13 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
if (normalized.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!DOWNLOAD_KEY_ALLOWED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizePlatformArchKey(key: string): string | null {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {MemeID, UserID} from '../BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
|
||||
import {Db} from '../database/CassandraTypes';
|
||||
import type {FavoriteMemeRow} from '../database/types/UserTypes';
|
||||
import {FavoriteMeme} from '../models/FavoriteMeme';
|
||||
import {FavoriteMemes, FavoriteMemesByMemeId} from '../Tables';
|
||||
@@ -99,6 +100,10 @@ export class FavoriteMemeRepository extends IFavoriteMemeRepository {
|
||||
return new FavoriteMeme(memeRow);
|
||||
}
|
||||
|
||||
async updatePlaceholder(userId: UserID, memeId: MemeID, placeholder: string): Promise<void> {
|
||||
await fetchOne(FavoriteMemes.patchByPk({user_id: userId, meme_id: memeId}, {placeholder: Db.set(placeholder)}));
|
||||
}
|
||||
|
||||
async delete(userId: UserID, memeId: MemeID): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(FavoriteMemes.deleteByPk({user_id: userId, meme_id: memeId}));
|
||||
|
||||
@@ -34,6 +34,8 @@ export abstract class IFavoriteMemeRepository {
|
||||
|
||||
abstract update(userId: UserID, memeId: MemeID, data: CreateFavoriteMemeParams): Promise<FavoriteMeme>;
|
||||
|
||||
abstract updatePlaceholder(userId: UserID, memeId: MemeID, placeholder: string): Promise<void>;
|
||||
|
||||
abstract delete(userId: UserID, memeId: MemeID): Promise<void>;
|
||||
|
||||
abstract deleteAllByUserId(userId: UserID): Promise<void>;
|
||||
|
||||
@@ -262,7 +262,7 @@ export function GuildMemberController(app: HonoApp) {
|
||||
const userId = ctx.get('user').id;
|
||||
const targetId = createUserID(user_id);
|
||||
const guildId = createGuildID(guild_id);
|
||||
const {delete_message_days, reason, ban_duration_seconds} = ctx.req.valid('json');
|
||||
const {delete_message_days, delete_message_seconds, reason, ban_duration_seconds} = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const effectiveReason = reason ?? auditLogReason ?? undefined;
|
||||
await ctx.get('guildService').moderation.banMember(
|
||||
@@ -271,6 +271,7 @@ export function GuildMemberController(app: HonoApp) {
|
||||
guildId,
|
||||
targetId,
|
||||
deleteMessageDays: delete_message_days,
|
||||
deleteMessageSeconds: delete_message_seconds,
|
||||
reason: effectiveReason,
|
||||
banDurationSeconds: ban_duration_seconds,
|
||||
},
|
||||
|
||||
@@ -14,9 +14,11 @@ import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
|
||||
import type {UserCacheService} from '../../infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '../../limits/LimitConfigService';
|
||||
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import type {GuildAuditLogService} from '../GuildAuditLogService';
|
||||
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
|
||||
import {ChannelOperationsService} from './channel/ChannelOperationsService';
|
||||
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||
|
||||
export class GuildChannelService {
|
||||
private readonly channelOps: ChannelOperationsService;
|
||||
@@ -30,6 +32,7 @@ export class GuildChannelService {
|
||||
snowflakeService: ISnowflakeService,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
limitConfigService: LimitConfigService,
|
||||
private readonly userRepository: IUserRepository,
|
||||
) {
|
||||
this.channelOps = new ChannelOperationsService(
|
||||
channelRepository,
|
||||
@@ -131,5 +134,12 @@ export class GuildChannelService {
|
||||
permission: params.permission,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({
|
||||
userRepository: this.userRepository,
|
||||
guildData,
|
||||
userId: params.userId,
|
||||
});
|
||||
enforceGuildMfa(params.permission);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export class GuildDataService {
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService);
|
||||
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService, this.userRepository);
|
||||
this.operationsService = new GuildOperationsService(
|
||||
this.guildRepository,
|
||||
this.channelRepository,
|
||||
|
||||
@@ -50,7 +50,7 @@ export class GuildMemberService {
|
||||
ipInfoService: IpInfoService,
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.authService = new GuildMemberAuthService(gatewayService);
|
||||
this.authService = new GuildMemberAuthService(gatewayService, userRepository);
|
||||
this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService);
|
||||
this.auditService = new GuildMemberAuditService(guildAuditLogService);
|
||||
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {UserID} from '../../BrandedTypes';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
|
||||
const ELEVATED_MFA_PERMISSIONS =
|
||||
Permissions.KICK_MEMBERS |
|
||||
Permissions.BAN_MEMBERS |
|
||||
Permissions.ADMINISTRATOR |
|
||||
Permissions.MANAGE_CHANNELS |
|
||||
Permissions.MANAGE_GUILD |
|
||||
Permissions.MANAGE_MESSAGES |
|
||||
Permissions.MANAGE_ROLES |
|
||||
Permissions.MANAGE_WEBHOOKS |
|
||||
Permissions.MODERATE_MEMBERS;
|
||||
|
||||
export async function createGuildMfaEnforcer(params: {
|
||||
userRepository: IUserRepository;
|
||||
guildData: Pick<GuildResponse, 'mfa_level' | 'owner_id'>;
|
||||
userId: UserID;
|
||||
}): Promise<(permission: bigint) => void> {
|
||||
const {userRepository, guildData, userId} = params;
|
||||
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
|
||||
let actorLacksMfa = false;
|
||||
if (requiresGuildMfa) {
|
||||
const actor = await userRepository.findUnique(userId);
|
||||
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
|
||||
}
|
||||
return (permission: bigint) => {
|
||||
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -27,8 +27,11 @@ import type {GuildAuditLogService} from '../GuildAuditLogService';
|
||||
import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
|
||||
import {mapGuildBansToResponse} from '../GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
|
||||
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||
import {GuildMemberSearchIndexService} from './member/GuildMemberSearchIndexService';
|
||||
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
|
||||
export class GuildModerationService {
|
||||
private readonly searchIndexService: GuildMemberSearchIndexService;
|
||||
|
||||
@@ -44,25 +47,43 @@ export class GuildModerationService {
|
||||
this.searchIndexService = new GuildMemberSearchIndexService();
|
||||
}
|
||||
|
||||
private async checkModerationPermission(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
permission: bigint;
|
||||
}): Promise<void> {
|
||||
const {guildId, userId, permission} = params;
|
||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||
enforceGuildMfa(permission);
|
||||
}
|
||||
|
||||
async banMember(
|
||||
params: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
deleteMessageDays?: number;
|
||||
deleteMessageSeconds?: number;
|
||||
reason?: string | null;
|
||||
banDurationSeconds?: number;
|
||||
skipGuildAuditLog?: boolean;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds, skipGuildAuditLog} = params;
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
const {
|
||||
userId,
|
||||
permission: Permissions.BAN_MEMBERS,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
guildId,
|
||||
targetId,
|
||||
deleteMessageDays,
|
||||
deleteMessageSeconds,
|
||||
reason,
|
||||
banDurationSeconds,
|
||||
skipGuildAuditLog,
|
||||
} = params;
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
if (userId === targetId) throw new UnknownGuildMemberError();
|
||||
const targetUser = await this.userRepository.findUnique(targetId);
|
||||
if (!targetUser) {
|
||||
@@ -73,11 +94,13 @@ export class GuildModerationService {
|
||||
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId: targetId});
|
||||
if (!canManage) throw new MissingPermissionsError();
|
||||
}
|
||||
if (deleteMessageDays && deleteMessageDays > 0) {
|
||||
const effectiveDeleteMessageSeconds =
|
||||
deleteMessageSeconds ?? (deleteMessageDays !== undefined ? deleteMessageDays * SECONDS_PER_DAY : undefined);
|
||||
if (effectiveDeleteMessageSeconds && effectiveDeleteMessageSeconds > 0) {
|
||||
await this.workerService.addJob('deleteUserMessagesInGuildByTime', {
|
||||
guildId: guildId.toString(),
|
||||
userId: targetId.toString(),
|
||||
days: deleteMessageDays,
|
||||
seconds: effectiveDeleteMessageSeconds,
|
||||
});
|
||||
}
|
||||
const targetIp = isIpBanExempt(targetUser.lastActiveIp) ? null : targetUser.lastActiveIp || null;
|
||||
@@ -145,12 +168,7 @@ export class GuildModerationService {
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<GuildBanResponse>> {
|
||||
const {userId, guildId, requestCache} = params;
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
permission: Permissions.BAN_MEMBERS,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
const bans = await this.guildRepository.listBans(guildId);
|
||||
return await mapGuildBansToResponse(bans, this.userCacheService, requestCache);
|
||||
}
|
||||
@@ -164,12 +182,7 @@ export class GuildModerationService {
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, targetId} = params;
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
permission: Permissions.BAN_MEMBERS,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
const ban = await this.guildRepository.getBan(guildId, targetId);
|
||||
if (!ban) {
|
||||
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {LimitConfigService} from '../../limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '../../limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder';
|
||||
import {GuildRole} from '../../models/GuildRole';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {applyProtectedRolePermissions} from '../../utils/featureUtils';
|
||||
import {computePermissionsDiff} from '../../utils/PermissionUtils';
|
||||
import type {GuildAuditLogService} from '../GuildAuditLogService';
|
||||
@@ -33,6 +34,7 @@ import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
|
||||
import {mapGuildRoleToResponse} from '../GuildModel';
|
||||
import type {IGuildMemberRepository} from '../repositories/IGuildMemberRepository';
|
||||
import type {IGuildRoleRepository} from '../repositories/IGuildRoleRepository';
|
||||
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||
|
||||
interface GuildRoleRepository extends IGuildRoleRepository, IGuildMemberRepository {}
|
||||
|
||||
@@ -62,6 +64,7 @@ export class GuildRoleService {
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
private readonly userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
async systemCreateRole(params: {
|
||||
@@ -305,7 +308,7 @@ export class GuildRoleService {
|
||||
position?: number;
|
||||
}>;
|
||||
},
|
||||
_auditLogReason?: string | null,
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, updates} = params;
|
||||
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
|
||||
@@ -316,7 +319,7 @@ export class GuildRoleService {
|
||||
throw new ResourceLockedError();
|
||||
}
|
||||
try {
|
||||
await this.updateRolePositionsByList({userId, guildId, updates});
|
||||
await this.updateRolePositionsByList({userId, guildId, updates, auditLogReason: auditLogReason ?? null});
|
||||
} finally {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
@@ -342,7 +345,7 @@ export class GuildRoleService {
|
||||
hoistPosition: number;
|
||||
}>;
|
||||
},
|
||||
_auditLogReason?: string | null,
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, updates} = params;
|
||||
const {checkPermission, guildData} = await this.getGuildAuthenticated({userId, guildId});
|
||||
@@ -400,6 +403,7 @@ export class GuildRoleService {
|
||||
}
|
||||
if (changedRoles.length > 0) {
|
||||
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
|
||||
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
|
||||
}
|
||||
} finally {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
@@ -411,7 +415,7 @@ export class GuildRoleService {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
},
|
||||
_auditLogReason?: string | null,
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId} = params;
|
||||
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
|
||||
@@ -423,6 +427,7 @@ export class GuildRoleService {
|
||||
}
|
||||
try {
|
||||
const allRoles = await this.guildRepository.listRoles(guildId);
|
||||
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
|
||||
const changedRoles: Array<GuildRole> = [];
|
||||
for (const role of allRoles) {
|
||||
if (role.hoistPosition === null) continue;
|
||||
@@ -438,6 +443,7 @@ export class GuildRoleService {
|
||||
}
|
||||
if (changedRoles.length > 0) {
|
||||
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
|
||||
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
|
||||
}
|
||||
} finally {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
@@ -446,9 +452,11 @@ export class GuildRoleService {
|
||||
|
||||
private async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||
const checkPermission = async (permission: bigint) => {
|
||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
enforceGuildMfa(permission);
|
||||
};
|
||||
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
|
||||
return {
|
||||
@@ -554,8 +562,9 @@ export class GuildRoleService {
|
||||
roleId: RoleID;
|
||||
position?: number;
|
||||
}>;
|
||||
auditLogReason?: string | null;
|
||||
}): Promise<void> {
|
||||
const {userId, guildId, updates} = params;
|
||||
const {userId, guildId, updates, auditLogReason} = params;
|
||||
const {guildData} = await this.getGuildAuthenticated({userId, guildId});
|
||||
const allRoles = await this.guildRepository.listRoles(guildId);
|
||||
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
|
||||
@@ -620,6 +629,7 @@ export class GuildRoleService {
|
||||
});
|
||||
if (changedRoles.length > 0) {
|
||||
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
|
||||
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,6 +704,30 @@ export class GuildRoleService {
|
||||
return newRoles;
|
||||
}
|
||||
|
||||
private async recordRolePositionAuditLogs(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
roleMap: Map<RoleID, GuildRole>;
|
||||
changedRoles: Array<GuildRole>;
|
||||
auditLogReason?: string | null;
|
||||
}): Promise<void> {
|
||||
const {guildId, userId, roleMap, changedRoles, auditLogReason} = params;
|
||||
for (const role of changedRoles) {
|
||||
const oldRole = roleMap.get(role.id);
|
||||
await this.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.ROLE_UPDATE,
|
||||
targetId: role.id,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.guildAuditLogService.computeChanges(
|
||||
oldRole ? this.serializeRoleForAudit(oldRole) : null,
|
||||
this.serializeRoleForAudit(role),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private serializeRoleForAudit(role: GuildRole): Record<string, unknown> {
|
||||
return {
|
||||
role_id: role.id.toString(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
@@ -22,6 +22,7 @@ import {buildMessageSearchFilters} from '../../search/BuildMessageSearchFilters'
|
||||
import {channelNeedsReindexing} from '../../search/ChannelIndexingUtils';
|
||||
import {MessageSearchResponseMapper} from '../../search/MessageSearchResponseMapper';
|
||||
import {searchExistingMessages} from '../../search/MessageSearchResultReconciler';
|
||||
import {channelRequiresAgeVerification} from '../../search/SearchNsfwUtils';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {canUserAccessNsfwContent} from '../../utils/AgeUtils';
|
||||
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
|
||||
@@ -84,6 +85,7 @@ export class GuildSearchService {
|
||||
}
|
||||
}
|
||||
const canIncludeNsfw = includeNsfwRequested && canUserAccessNsfw;
|
||||
const guildNsfw = guildData?.nsfw ?? false;
|
||||
const channels = await this.channelRepository.listChannels(channelIds);
|
||||
const channelMap = new Map<string, Channel>();
|
||||
for (const channel of channels) {
|
||||
@@ -96,9 +98,10 @@ export class GuildSearchService {
|
||||
throw InputValidationError.fromCode('channel_ids', ValidationErrorCodes.ALL_CHANNELS_MUST_BELONG_TO_GUILD);
|
||||
}
|
||||
}
|
||||
const categoryLookup = await this.buildParentCategoryLookup(channelMap);
|
||||
const nsfwFilteredIds = channelIds.filter((id) => {
|
||||
const channel = channelMap.get(id.toString())!;
|
||||
return !(channel.isNsfw && !canIncludeNsfw);
|
||||
return !(channelRequiresAgeVerification(channel, categoryLookup, guildNsfw) && !canIncludeNsfw);
|
||||
});
|
||||
const permissionResults = await mapWithConcurrency(nsfwFilteredIds, PERMISSION_CHECK_CONCURRENCY, (channelId) =>
|
||||
this.gatewayService.checkPermission({
|
||||
@@ -188,7 +191,7 @@ export class GuildSearchService {
|
||||
if (!searchService) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} =
|
||||
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} =
|
||||
await this.collectAccessibleGuildChannels(userId);
|
||||
if (unindexedChannelIds.size > 0) {
|
||||
await this.queueIndexingChannels(unindexedChannelIds);
|
||||
@@ -219,7 +222,7 @@ export class GuildSearchService {
|
||||
if (guildIsAgeRestricted) {
|
||||
return canIncludeNsfw;
|
||||
}
|
||||
if (channel.isNsfw) {
|
||||
if (channelRequiresAgeVerification(channel, parentCategories, false)) {
|
||||
return canIncludeNsfw;
|
||||
}
|
||||
return true;
|
||||
@@ -259,6 +262,24 @@ export class GuildSearchService {
|
||||
};
|
||||
}
|
||||
|
||||
private async buildParentCategoryLookup(channelMap: Map<string, Channel>): Promise<Map<string, Channel>> {
|
||||
const lookup = new Map<string, Channel>(channelMap);
|
||||
const missingParentIds: Array<ChannelID> = [];
|
||||
for (const channel of channelMap.values()) {
|
||||
const parentId = channel.parentId;
|
||||
if (parentId != null && !lookup.has(parentId.toString())) {
|
||||
missingParentIds.push(parentId);
|
||||
}
|
||||
}
|
||||
if (missingParentIds.length > 0) {
|
||||
const parents = await this.channelRepository.listChannels(missingParentIds);
|
||||
for (const parent of parents) {
|
||||
lookup.set(parent.id.toString(), parent);
|
||||
}
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private async getCanUserAccessNsfw(userId: UserID): Promise<boolean> {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
@@ -284,11 +305,13 @@ export class GuildSearchService {
|
||||
accessibleChannels: Map<string, Channel>;
|
||||
unindexedChannelIds: Set<string>;
|
||||
guildNsfwLevels: Map<string, number>;
|
||||
parentCategories: Map<string, Channel>;
|
||||
}> {
|
||||
const guildIds = await this.userRepository.getUserGuildIds(userId);
|
||||
const accessibleChannels = new Map<string, Channel>();
|
||||
const unindexedChannelIds = new Set<string>();
|
||||
const guildNsfwLevels = new Map<string, number>();
|
||||
const parentCategories = new Map<string, Channel>();
|
||||
const permissionChecks: Array<{
|
||||
channel: Channel;
|
||||
guildId: GuildID;
|
||||
@@ -304,6 +327,9 @@ export class GuildSearchService {
|
||||
}
|
||||
const viewableChannelIds = new Set(viewableChannels.map((channelId) => channelId.toString()));
|
||||
for (const channel of guildChannels) {
|
||||
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
||||
parentCategories.set(channel.id.toString(), channel);
|
||||
}
|
||||
if (viewableChannelIds.has(channel.id.toString())) {
|
||||
permissionChecks.push({channel, guildId});
|
||||
}
|
||||
@@ -331,6 +357,6 @@ export class GuildSearchService {
|
||||
unindexedChannelIds.add(channelIdStr);
|
||||
}
|
||||
}
|
||||
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels};
|
||||
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures, GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
@@ -44,6 +43,7 @@ import {GuildChannelService} from './GuildChannelService';
|
||||
import {GuildContentService} from './GuildContentService';
|
||||
import {GuildDataService} from './GuildDataService';
|
||||
import {GuildMemberService} from './GuildMemberService';
|
||||
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||
import {GuildModerationService} from './GuildModerationService';
|
||||
import {GuildRoleService} from './GuildRoleService';
|
||||
import {GuildSearchService} from './GuildSearchService';
|
||||
@@ -95,17 +95,6 @@ interface GuildAuth {
|
||||
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const ELEVATED_MFA_PERMISSIONS =
|
||||
Permissions.KICK_MEMBERS |
|
||||
Permissions.BAN_MEMBERS |
|
||||
Permissions.ADMINISTRATOR |
|
||||
Permissions.MANAGE_CHANNELS |
|
||||
Permissions.MANAGE_GUILD |
|
||||
Permissions.MANAGE_MESSAGES |
|
||||
Permissions.MANAGE_ROLES |
|
||||
Permissions.MANAGE_WEBHOOKS |
|
||||
Permissions.MODERATE_MEMBERS;
|
||||
|
||||
export class GuildService {
|
||||
public readonly data: GuildDataService;
|
||||
public readonly members: GuildMemberService;
|
||||
@@ -182,6 +171,7 @@ export class GuildService {
|
||||
gatewayService,
|
||||
guildAuditLogService,
|
||||
limitConfigService,
|
||||
userRepository,
|
||||
);
|
||||
this.moderation = new GuildModerationService(
|
||||
guildRepository,
|
||||
@@ -211,6 +201,7 @@ export class GuildService {
|
||||
snowflakeService,
|
||||
guildAuditLogService,
|
||||
limitConfigService,
|
||||
userRepository,
|
||||
);
|
||||
this.search = new GuildSearchService(
|
||||
channelRepository,
|
||||
@@ -561,17 +552,7 @@ export class GuildService {
|
||||
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
if (!guildData) throw new MissingAccessError();
|
||||
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
|
||||
let actorLacksMfa = false;
|
||||
if (requiresGuildMfa) {
|
||||
const actor = await this.userRepository.findUnique(userId);
|
||||
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
|
||||
}
|
||||
const enforceGuildMfa = (permission: bigint) => {
|
||||
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
};
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||
const checkPermission = async (permission: bigint) => {
|
||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
|
||||
@@ -355,12 +355,18 @@ export class ChannelOperationsService {
|
||||
requestCache,
|
||||
});
|
||||
if (update.lockPermissions && desiredParent && desiredParent !== (target.parentId ?? null)) {
|
||||
await this.syncPermissionsWithParent({guildId, channelId: target.id, parentId: desiredParent});
|
||||
await this.syncPermissionsWithParent({
|
||||
guildId,
|
||||
userId: params.userId,
|
||||
channelId: target.id,
|
||||
parentId: desiredParent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async syncPermissionsWithParent(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
parentId: ChannelID;
|
||||
}): Promise<void> {
|
||||
@@ -368,6 +374,22 @@ export class ChannelOperationsService {
|
||||
if (!parent || parent.guildId !== params.guildId || parent.type !== ChannelTypes.GUILD_CATEGORY) return;
|
||||
const child = await this.channelRepository.findUnique(params.channelId);
|
||||
if (!child || child.guildId !== params.guildId) return;
|
||||
const userPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
channelId: child.id,
|
||||
});
|
||||
if ((userPermissions & Permissions.MANAGE_ROLES) === 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
for (const [targetId, existing] of child.permissionOverwrites) {
|
||||
const incomingDeny = parent.permissionOverwrites.get(targetId)?.deny ?? 0n;
|
||||
if ((existing.deny & ~incomingDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
}
|
||||
for (const [targetId, incoming] of parent.permissionOverwrites) {
|
||||
const existingAllow = child.permissionOverwrites.get(targetId)?.allow ?? 0n;
|
||||
if ((incoming.allow & ~existingAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
}
|
||||
await this.channelRepository.upsert({
|
||||
...child.toRow(),
|
||||
permission_overwrites: new Map(
|
||||
|
||||
@@ -8,12 +8,14 @@ import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '../..
|
||||
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||
import {Logger} from '../../../Logger';
|
||||
import type {Guild} from '../../../models/Guild';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {serializeGuildForAudit as serializeGuildForAuditUtil} from '../../../utils/AuditSerializationUtils';
|
||||
import {requirePermission} from '../../../utils/PermissionUtils';
|
||||
import type {GuildAuditLogService} from '../../GuildAuditLogService';
|
||||
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
|
||||
import {mapGuildToGuildResponse} from '../../GuildModel';
|
||||
import {GuildRepository} from '../../repositories/GuildRepository';
|
||||
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
|
||||
|
||||
interface GuildAuth {
|
||||
guildData: GuildResponse;
|
||||
@@ -24,6 +26,7 @@ export class GuildDataHelpers {
|
||||
constructor(
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private readonly userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
private readonly guildRepository = new GuildRepository();
|
||||
@@ -33,7 +36,7 @@ export class GuildDataHelpers {
|
||||
try {
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
if (!guildData) throw new UnknownGuildError();
|
||||
return this.createGuildAuth({guildData, guildId, userId});
|
||||
return await this.createGuildAuth({guildData, guildId, userId});
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownGuildError && (await this.guildExists(guildId))) {
|
||||
throw new AccessDeniedError();
|
||||
@@ -42,10 +45,16 @@ export class GuildDataHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
private createGuildAuth(params: {guildData: GuildResponse; guildId: GuildID; userId: UserID}): GuildAuth {
|
||||
private async createGuildAuth(params: {
|
||||
guildData: GuildResponse;
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
}): Promise<GuildAuth> {
|
||||
const {guildData, guildId, userId} = params;
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||
const checkPermission = async (permission: bigint) => {
|
||||
await requirePermission(this.gatewayService, {guildId, userId, permission});
|
||||
enforceGuildMfa(permission);
|
||||
};
|
||||
return {guildData, checkPermission};
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ import {Guild} from '../../../models/Guild';
|
||||
import type {User} from '../../../models/User';
|
||||
import {getGuildSearchService} from '../../../SearchFactory';
|
||||
import type {GuildDiscoveryContext} from '../../../search/guild/GuildSearchSerializer';
|
||||
import {deleteGuildMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
|
||||
import {deleteChannelMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
|
||||
import {Channels, ChannelsByGuild, GuildMembers, GuildMembersByUserId, GuildRoles, Guilds} from '../../../Tables';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {mapUserSettingsToResponse} from '../../../user/UserMappers';
|
||||
@@ -858,7 +858,11 @@ export class GuildOperationsService {
|
||||
await Promise.all(webhooks.map((webhook) => this.webhookRepository.delete(webhook.id)));
|
||||
const channels = await this.channelRepository.listGuildChannels(guildId);
|
||||
await Promise.all(channels.map((channel) => this.channelRepository.deleteAllChannelMessages(channel.id)));
|
||||
await deleteGuildMessageSearchDocuments(guildId, {context: {source: 'guild_delete'}});
|
||||
await Promise.all(
|
||||
channels.map((channel) =>
|
||||
deleteChannelMessageSearchDocuments(channel.id, {context: {source: 'guild_delete'}}),
|
||||
),
|
||||
);
|
||||
await Promise.all(channels.map((channel) => this.channelService.attachments.purgeChannelAttachments(channel)));
|
||||
const discoveryRow = await this.discoveryRepository.findByGuildId(guildId);
|
||||
if (discoveryRow) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPe
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
|
||||
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
|
||||
|
||||
interface GuildAuth {
|
||||
guildData: GuildResponse;
|
||||
@@ -16,22 +18,30 @@ interface GuildAuth {
|
||||
}
|
||||
|
||||
export class GuildMemberAuthService {
|
||||
constructor(private readonly gatewayService: IGatewayService) {}
|
||||
constructor(
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
if (!guildData) throw new MissingAccessError();
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||
const checkPermission = async (permission: bigint) => {
|
||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
enforceGuildMfa(permission);
|
||||
};
|
||||
const checkTargetMember = async (targetUserId: UserID) => {
|
||||
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId});
|
||||
if (!canManage) throw new MissingPermissionsError();
|
||||
};
|
||||
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
|
||||
const hasPermission = async (permission: bigint) =>
|
||||
this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
const hasPermission = async (permission: bigint) => {
|
||||
const allowed = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
if (allowed) enforceGuildMfa(permission);
|
||||
return allowed;
|
||||
};
|
||||
const canManageRoles = async (targetUserId: UserID, targetRoleId: RoleID) =>
|
||||
this.gatewayService.canManageRoles({guildId, userId, targetUserId, roleId: targetRoleId});
|
||||
return {
|
||||
|
||||
@@ -42,7 +42,7 @@ export class GuildMemberRoleService {
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {userId, targetId, guildId, roleId} = params;
|
||||
const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
|
||||
const {guildData, hasPermission, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
await this.validationService.validateRoleAssignment({
|
||||
@@ -51,6 +51,7 @@ export class GuildMemberRoleService {
|
||||
userId,
|
||||
targetId,
|
||||
roleId,
|
||||
hasPermission,
|
||||
canManageRoles,
|
||||
});
|
||||
if (targetMember.roleIds.has(roleId)) return;
|
||||
@@ -75,7 +76,7 @@ export class GuildMemberRoleService {
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {userId, targetId, guildId, roleId} = params;
|
||||
const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
|
||||
const {guildData, hasPermission, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
await this.validationService.validateRoleAssignment({
|
||||
@@ -84,6 +85,7 @@ export class GuildMemberRoleService {
|
||||
userId,
|
||||
targetId,
|
||||
roleId,
|
||||
hasPermission,
|
||||
canManageRoles,
|
||||
});
|
||||
if (!targetMember.roleIds.has(roleId)) return;
|
||||
|
||||
@@ -74,9 +74,10 @@ export class GuildMemberValidationService {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
roleId: RoleID;
|
||||
hasPermission: (permission: bigint) => Promise<boolean>;
|
||||
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
|
||||
}): Promise<void> {
|
||||
const {guildData, guildId, userId, targetId, roleId, canManageRoles} = params;
|
||||
const {guildData, guildId, userId, targetId, roleId, hasPermission, canManageRoles} = params;
|
||||
ensureNotEveryoneRole(roleId, guildId, 'role_id');
|
||||
if (guildData && guildData.owner_id === userId.toString()) {
|
||||
const role = await this.guildRepository.getRole(roleId, guildId);
|
||||
@@ -84,6 +85,9 @@ export class GuildMemberValidationService {
|
||||
throw new UnknownGuildRoleError();
|
||||
}
|
||||
} else {
|
||||
if (!(await hasPermission(Permissions.MANAGE_ROLES))) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
if (!(await canManageRoles(targetId, roleId))) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface IStorageService {
|
||||
key: string;
|
||||
uploadId: string;
|
||||
partNumber: number;
|
||||
contentLength?: number;
|
||||
expiresIn?: number;
|
||||
}): Promise<string>;
|
||||
purgeBucket(bucket: string): Promise<void>;
|
||||
|
||||
@@ -296,12 +296,14 @@ export class StorageService implements IStorageService {
|
||||
key,
|
||||
uploadId,
|
||||
partNumber,
|
||||
contentLength,
|
||||
expiresIn = seconds('1 hour'),
|
||||
}: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
uploadId: string;
|
||||
partNumber: number;
|
||||
contentLength?: number;
|
||||
expiresIn?: number;
|
||||
}): Promise<string> {
|
||||
const command = new UploadPartCommand({
|
||||
@@ -309,6 +311,7 @@ export class StorageService implements IStorageService {
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
PartNumber: partNumber,
|
||||
ContentLength: contentLength,
|
||||
});
|
||||
return getSignedUrl(this.presignClient, command, {expiresIn});
|
||||
}
|
||||
|
||||
@@ -52,7 +52,11 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean {
|
||||
function getClientIdentifier(ctx: Context<HonoEnv>): string {
|
||||
const user = ctx.get('user');
|
||||
if (user?.id) {
|
||||
return `user:${user.id}`;
|
||||
const tokenType = ctx.get('authTokenType') ?? 'session';
|
||||
if (tokenType === 'bearer') {
|
||||
return `user:${user.id}:bearer:${ctx.get('oauthBearerApplicationId') ?? 'unknown'}`;
|
||||
}
|
||||
return `user:${user.id}:${tokenType}`;
|
||||
}
|
||||
const ip = extractClientIp(ctx.req.raw, {
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
|
||||
@@ -160,7 +160,7 @@ function createEmailServiceForConfig(
|
||||
enabled: emailConfigSource.enabled,
|
||||
fromEmail: emailConfigSource.fromEmail,
|
||||
fromName: emailConfigSource.fromName,
|
||||
appBaseUrl: Config.endpoints.webApp,
|
||||
appBaseUrl: emailConfigSource.appBaseUrl,
|
||||
marketingBaseUrl: Config.endpoints.marketing,
|
||||
};
|
||||
return new EmailService(emailConfig, emailI18n, createEmailProvider(emailConfigSource), bouncedEmailChecker);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -30823,7 +30823,14 @@
|
||||
"minimum": 0,
|
||||
"maximum": 7,
|
||||
"format": "int32",
|
||||
"description": "Number of days of messages to delete from the banned user (0-7)"
|
||||
"description": "Number of days of messages to delete from the banned user (0-7). Deprecated in favor of delete_message_seconds."
|
||||
},
|
||||
"delete_message_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 604800,
|
||||
"format": "int32",
|
||||
"description": "Number of seconds of messages to delete for the banned user (0-604800, default 0)"
|
||||
},
|
||||
"reason": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
|
||||
@@ -30,6 +30,7 @@ import {channelNeedsReindexing} from './ChannelIndexingUtils';
|
||||
import type {IMessageSearchService} from './IMessageSearchService';
|
||||
import {MessageSearchResponseMapper} from './MessageSearchResponseMapper';
|
||||
import {searchExistingMessages} from './MessageSearchResultReconciler';
|
||||
import {channelRequiresAgeVerification} from './SearchNsfwUtils';
|
||||
|
||||
const CHANNEL_INDEX_CHECK_CONCURRENCY = 32;
|
||||
const CHANNEL_INDEX_JOB_ENQUEUE_CONCURRENCY = 16;
|
||||
@@ -101,7 +102,7 @@ export class GlobalSearchService {
|
||||
this.guildService.search.collectAccessibleGuildChannels(params.userId),
|
||||
this.findDmScopeContextChannel(params.userId, params.includeChannelId),
|
||||
]);
|
||||
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} = guildAccess;
|
||||
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} = guildAccess;
|
||||
if (unindexedChannelIds.size > 0) {
|
||||
await this.queueIndexingChannels(unindexedChannelIds);
|
||||
return {indexing: true};
|
||||
@@ -133,7 +134,7 @@ export class GlobalSearchService {
|
||||
if (guildIsAgeRestricted) {
|
||||
return canIncludeNsfw;
|
||||
}
|
||||
if (channel.isNsfw) {
|
||||
if (channelRequiresAgeVerification(channel, parentCategories, false)) {
|
||||
return canIncludeNsfw;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type ChannelID, createMessageID, type GuildID, type MessageID} from '../BrandedTypes';
|
||||
import {type ChannelID, createMessageID, type MessageID} from '../BrandedTypes';
|
||||
import {Logger} from '../Logger';
|
||||
import {getMessageSearchService} from '../SearchFactory';
|
||||
import type {IMessageSearchService} from './IMessageSearchService';
|
||||
@@ -59,18 +59,3 @@ export async function deleteChannelMessageSearchDocuments(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGuildMessageSearchDocuments(guildId: GuildID, options: CleanupOptions = {}): Promise<void> {
|
||||
const searchService = resolveMessageSearchService(options.searchService);
|
||||
if (!searchService) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await searchService.deleteGuildMessages(guildId);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{...options.context, guildId: guildId.toString(), error},
|
||||
'Failed to delete guild message documents from search index',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {channelToContentWarningView, computeEffectiveChannelNsfw} from '../channel/utils/EffectiveContentWarning';
|
||||
import type {Channel} from '../models/Channel';
|
||||
|
||||
export function channelRequiresAgeVerification(
|
||||
channel: Channel,
|
||||
channelsById: ReadonlyMap<string, Channel>,
|
||||
guildNsfw: boolean,
|
||||
): boolean {
|
||||
const parentCategory =
|
||||
channel.parentId != null ? (channelsById.get(channel.parentId.toString()) ?? null) : null;
|
||||
return computeEffectiveChannelNsfw(
|
||||
channelToContentWarningView(channel),
|
||||
parentCategory ? channelToContentWarningView(parentCategory) : null,
|
||||
{nsfw: guildNsfw, contentWarningLevel: ContentWarningLevel.INHERIT, contentWarningText: null},
|
||||
);
|
||||
}
|
||||
@@ -123,6 +123,9 @@ export class StripeDisputeWebhookHandler {
|
||||
reason: 'gift_refund',
|
||||
chargeId: charge.id,
|
||||
});
|
||||
} else if (!giftCode.revokedAt) {
|
||||
await this.userRepository.revokeGiftCode(giftCode.code);
|
||||
Logger.debug({giftCode: giftCode.code, chargeId: charge.id}, 'Revoked unredeemed gift code after refund');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -196,6 +199,12 @@ export class StripeDisputeWebhookHandler {
|
||||
{giftCode: giftCode.code, redeemerId: giftCode.redeemedByUserId},
|
||||
'Premium revoked due to gift chargeback',
|
||||
);
|
||||
} else if (!giftCode.revokedAt) {
|
||||
await this.userRepository.revokeGiftCode(giftCode.code);
|
||||
Logger.debug(
|
||||
{giftCode: giftCode.code, chargeId: extractId(dispute.charge)},
|
||||
'Revoked unredeemed gift code after chargeback',
|
||||
);
|
||||
}
|
||||
await this.paymentFraudService.enforceAccountFraudAction({
|
||||
userId: giftCode.createdByUserId,
|
||||
|
||||
@@ -40,7 +40,7 @@ export class StripeGiftService {
|
||||
|
||||
async getGiftCode(code: string): Promise<GiftCode> {
|
||||
const giftCode = await this.userRepository.findGiftCode(code);
|
||||
if (!giftCode) {
|
||||
if (!giftCode || giftCode.revokedAt) {
|
||||
throw new UnknownGiftCodeError();
|
||||
}
|
||||
return giftCode;
|
||||
@@ -62,7 +62,7 @@ export class StripeGiftService {
|
||||
}
|
||||
try {
|
||||
const giftCode = await this.userRepository.findGiftCode(code);
|
||||
if (!giftCode) {
|
||||
if (!giftCode || giftCode.revokedAt) {
|
||||
Logger.debug({userId, giftCode: code}, 'Gift code not found during redemption');
|
||||
throw new UnknownGiftCodeError();
|
||||
}
|
||||
@@ -258,6 +258,7 @@ export class StripeGiftService {
|
||||
const redeemedGracePeriodMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const cutoff = Date.now() - redeemedGracePeriodMs;
|
||||
return gifts
|
||||
.filter((gift) => gift.revokedAt === null)
|
||||
.filter((gift) => gift.redeemedAt === null || gift.redeemedAt.getTime() > cutoff)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ export class StripeRefundService {
|
||||
return;
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user || user.firstRefundAt) {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const subscriptionId = refund.metadata.subscription_id;
|
||||
|
||||
@@ -439,7 +439,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
test('skips zero-amount subscription_update invoice without granting an extra monthly cycle', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = `sub_test_${Date.now()}`;
|
||||
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
|
||||
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({
|
||||
@@ -481,7 +481,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
test('skips paid subscription_update invoices so interval switches do not grant extra time', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = `sub_test_${Date.now()}`;
|
||||
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
|
||||
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({
|
||||
|
||||
@@ -37,6 +37,7 @@ export class TestMediaService extends IMediaService {
|
||||
height: 128,
|
||||
animated: stored ? this.isAnimatedImage(stored, format) : format === 'gif',
|
||||
nsfw: false,
|
||||
placeholder: this.fakePlaceholder(request.key),
|
||||
};
|
||||
}
|
||||
if (request.type === 'upload') {
|
||||
@@ -55,6 +56,7 @@ export class TestMediaService extends IMediaService {
|
||||
height: 128,
|
||||
animated: format === 'gif',
|
||||
nsfw: false,
|
||||
placeholder: this.fakePlaceholder(request.upload_filename),
|
||||
};
|
||||
}
|
||||
if (request.type === 'external') {
|
||||
@@ -67,11 +69,16 @@ export class TestMediaService extends IMediaService {
|
||||
height: 128,
|
||||
animated: false,
|
||||
nsfw: false,
|
||||
placeholder: this.fakePlaceholder(request.url),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private fakePlaceholder(seed: string): string {
|
||||
return crypto.createHash('sha256').update(seed).digest('base64').slice(0, 24);
|
||||
}
|
||||
|
||||
getExternalMediaProxyURL(): string {
|
||||
return 'https://media-proxy.test';
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ function normaliseGiftCodeRowForWrite(data: GiftCodeRow): GiftCodeRow {
|
||||
duration_type: durationType,
|
||||
duration_quantity: durationQuantity,
|
||||
duration_months: durationMonths,
|
||||
revoked_at: data.revoked_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,6 +172,10 @@ export class GiftCodeRepository {
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async revokeGiftCode(code: string): Promise<void> {
|
||||
await upsertOne(GiftCodes.patchByPk({code}, {revoked_at: Db.set(new Date())}));
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
const patch: Record<string, DbOp<unknown>> = {};
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface IUserContentRepository {
|
||||
findGiftCodesByRedeemer(userId: UserID): Promise<Array<GiftCode>>;
|
||||
redeemGiftCode(code: string, userId: UserID): Promise<void>;
|
||||
unredeemGiftCode(code: string, userId: UserID): Promise<void>;
|
||||
revokeGiftCode(code: string): Promise<void>;
|
||||
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
|
||||
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
|
||||
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
|
||||
|
||||
@@ -62,6 +62,10 @@ export class UserContentRepository implements IUserContentRepository {
|
||||
return this.giftCodeRepository.unredeemGiftCode(code, userId);
|
||||
}
|
||||
|
||||
async revokeGiftCode(code: string): Promise<void> {
|
||||
return this.giftCodeRepository.revokeGiftCode(code);
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
return this.giftCodeRepository.updateGiftCode(code, data);
|
||||
}
|
||||
|
||||
@@ -645,6 +645,10 @@ export class UserRepository implements IUserRepositoryAggregate {
|
||||
return this.contentRepo.unredeemGiftCode(code, userId);
|
||||
}
|
||||
|
||||
async revokeGiftCode(code: string): Promise<void> {
|
||||
return this.contentRepo.revokeGiftCode(code);
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
return this.contentRepo.updateGiftCode(code, data);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export class TokenRepository {
|
||||
}
|
||||
|
||||
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
|
||||
await upsertOne(EmailRevertTokens.insert(tokenData));
|
||||
await upsertOne(EmailRevertTokens.insertWithTtl(tokenData, seconds('24 hours')));
|
||||
return new EmailRevertToken(tokenData);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,14 +89,17 @@ export class UserAccountLookupService {
|
||||
let guildMember: GuildMemberResponse | null = null;
|
||||
let guildMemberDomain: GuildMember | null = null;
|
||||
if (guildId != null) {
|
||||
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
|
||||
if (guildMemberDomain) {
|
||||
guildMember = await this.deps.guildService.members.getMember({
|
||||
userId,
|
||||
targetId,
|
||||
guildId,
|
||||
requestCache,
|
||||
});
|
||||
const viewerMember = await this.deps.guildRepository.getMember(guildId, userId);
|
||||
if (viewerMember) {
|
||||
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
|
||||
if (guildMemberDomain) {
|
||||
guildMember = await this.deps.guildService.members.getMember({
|
||||
userId,
|
||||
targetId,
|
||||
guildId,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let premiumType = user.premiumType ?? undefined;
|
||||
|
||||
@@ -118,15 +118,17 @@ export class UserRelationshipService {
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
if (!staffForceAccept && (await getInstanceConfigRepository().getInstancePolicyConfig()).direct_messages_disabled) {
|
||||
const requesterUser = await this.userRepository.findUnique(userId);
|
||||
const requesterIsStaff =
|
||||
requesterUser != null && (requesterUser.flags & UserFlags.STAFF) === UserFlags.STAFF;
|
||||
if (
|
||||
!requesterIsStaff &&
|
||||
(await getInstanceConfigRepository().getInstancePolicyConfig()).direct_messages_disabled
|
||||
) {
|
||||
throw new DirectMessagesDisabledError();
|
||||
}
|
||||
const requesterUser = await this.userRepository.findUnique(userId);
|
||||
if (staffForceAccept) {
|
||||
const requesterIsStaff = requesterUser != null && (requesterUser.flags & UserFlags.STAFF) === UserFlags.STAFF;
|
||||
if (requesterIsStaff) {
|
||||
return await this.forceCreateFriendship({userId, targetId, userCacheService, requestCache});
|
||||
}
|
||||
if (staffForceAccept && requesterIsStaff) {
|
||||
return await this.forceCreateFriendship({userId, targetId, userCacheService, requestCache});
|
||||
}
|
||||
if (!requesterUser) {
|
||||
throw new UnknownUserError();
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
import {MessageAttachmentFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {AttachmentDecayRepository} from '../../attachment/AttachmentDecayRepository';
|
||||
import {createAttachmentID} from '../../BrandedTypes';
|
||||
import {createAttachmentID, createMemeID, createUserID} from '../../BrandedTypes';
|
||||
import {
|
||||
createTestAccountForAttachmentTests,
|
||||
sendMessageWithAttachments,
|
||||
setupTestGuildAndChannel,
|
||||
} from '../../channel/tests/AttachmentTestUtils';
|
||||
import {fetchOne} from '../../database/CassandraQueryExecution';
|
||||
import {Db} from '../../database/CassandraTypes';
|
||||
import {FavoriteMemes} from '../../Tables';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
@@ -42,6 +45,24 @@ interface MessageWithDecayAttachment {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MessageWithPlaceholderAttachment {
|
||||
id: string;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
filename: string;
|
||||
placeholder?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function clearFavoriteMemePlaceholder(userId: string, memeId: string) {
|
||||
await fetchOne(
|
||||
FavoriteMemes.patchByPk(
|
||||
{user_id: createUserID(BigInt(userId)), meme_id: createMemeID(BigInt(memeId))},
|
||||
{placeholder: Db.set(null)},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchDecayRow(attachmentId: string) {
|
||||
return new AttachmentDecayRepository().fetchById(createAttachmentID(BigInt(attachmentId)));
|
||||
}
|
||||
@@ -111,6 +132,43 @@ describe('Favorite Meme Operations', () => {
|
||||
expect(sent.attachments[0].filename).toBe(filename);
|
||||
expect(sent.attachments[0].flags & MessageAttachmentFlags.IS_ANIMATED).toBe(MessageAttachmentFlags.IS_ANIMATED);
|
||||
});
|
||||
test('should carry the saved placeholder onto the sent attachment', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Placeholder Meme',
|
||||
});
|
||||
expect(meme.placeholder).toBeTruthy();
|
||||
const sent = await createBuilder<MessageWithPlaceholderAttachment>(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({favorite_meme_id: meme.id})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(sent.attachments[0].placeholder).toBe(meme.placeholder);
|
||||
});
|
||||
test('should read-repair a missing placeholder when sending a favorite meme', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Repair Meme',
|
||||
});
|
||||
expect(meme.placeholder).toBeTruthy();
|
||||
await clearFavoriteMemePlaceholder(account.userId, meme.id);
|
||||
const stripped = await getFavoriteMeme(harness, account.token, meme.id);
|
||||
expect(stripped.placeholder).toBeNull();
|
||||
const sent = await createBuilder<MessageWithPlaceholderAttachment>(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({favorite_meme_id: meme.id})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(sent.attachments[0].placeholder).toBeTruthy();
|
||||
const repaired = await getFavoriteMeme(harness, account.token, meme.id);
|
||||
expect(repaired.placeholder).toBe(sent.attachments[0].placeholder);
|
||||
});
|
||||
test('should create decay metadata when sending favorite meme', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import type {WorkerTaskHandler, WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {z} from 'zod';
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import {Logger} from '../../Logger';
|
||||
import {getWorkerDependencies} from '../WorkerContext';
|
||||
|
||||
const MENTION_CHUNK_SIZE = 250;
|
||||
const MENTION_CHUNK_KEY_DIGEST_LENGTH = 32;
|
||||
const MENTION_CHUNK_ENQUEUE_CONCURRENCY = 16;
|
||||
const MENTION_SOURCE_PAGE_SIZE = 5000;
|
||||
const PayloadSchema = z.object({
|
||||
@@ -100,6 +102,14 @@ function toMentionChunkEntry(entry: GatewayMentionSourceEntry): MentionChunkEntr
|
||||
};
|
||||
}
|
||||
|
||||
function mentionChunkJobKey(messageId: string, chunk: Array<MentionChunkEntry>): string {
|
||||
const digest = createHash('sha256');
|
||||
for (const entry of chunk) {
|
||||
digest.update(`${entry.userId}:${entry.direct ? 1 : 0}:${entry.role ? 1 : 0}:${entry.everyone ? 1 : 0}\n`);
|
||||
}
|
||||
return `mention-chunk:${messageId}:${digest.digest('hex').slice(0, MENTION_CHUNK_KEY_DIGEST_LENGTH)}`;
|
||||
}
|
||||
|
||||
async function enqueueMentionChunks({
|
||||
chunks,
|
||||
channelId,
|
||||
@@ -122,7 +132,6 @@ async function enqueueMentionChunks({
|
||||
await Promise.all(
|
||||
chunkSlice.map((chunk, localIndex) => {
|
||||
const index = firstChunkIndex + offset + localIndex;
|
||||
const jobKeySuffix = chunkCount === undefined ? `${index}` : `${index}:${chunkCount}`;
|
||||
return addJob(
|
||||
'handleMentionChunk',
|
||||
{
|
||||
@@ -134,7 +143,7 @@ async function enqueueMentionChunks({
|
||||
mentions: chunk,
|
||||
},
|
||||
{
|
||||
jobKey: `mention-chunk:${messageId}:${jobKeySuffix}`,
|
||||
jobKey: mentionChunkJobKey(messageId, chunk),
|
||||
skipLedger: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '../../SearchFactory';
|
||||
import type {IGuildMemberSearchService} from '../../search/IGuildMemberSearchService';
|
||||
import type {IMessageSearchService} from '../../search/IMessageSearchService';
|
||||
import {deleteChannelMessageSearchDocuments} from '../../search/MessageSearchIndexCleanup';
|
||||
import {getWorkerDependencies} from '../WorkerContext';
|
||||
|
||||
const INDEX_TYPES = [
|
||||
@@ -190,11 +191,16 @@ const refreshChannelMessages: IndexHandler = async (payload, helpers, kvClient,
|
||||
const {channelRepository} = getWorkerDependencies();
|
||||
const guildId = createGuildID(BigInt(payload.guild_id!));
|
||||
const searchService = requireSearchService<IMessageSearchService>(getMessageSearchService());
|
||||
await searchService.deleteGuildMessages(guildId);
|
||||
const channels = await channelRepository.listGuildChannels(guildId);
|
||||
if (channels.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
for (const channel of channels) {
|
||||
await deleteChannelMessageSearchDocuments(channel.id, {
|
||||
searchService,
|
||||
context: {source: 'bulk_reindex', guildId: guildId.toString()},
|
||||
});
|
||||
}
|
||||
const completionKey = `bulk_reindex:${payload.job_id}:remaining`;
|
||||
await kvClient.del(completionKey);
|
||||
for (const channel of channels) {
|
||||
|
||||
@@ -33,6 +33,7 @@ export const ZOOM_LEVEL_MARKERS = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] as cons
|
||||
export const ACCESSIBILITY_STORE_STORAGE_KEY = 'Accessibility';
|
||||
export const ACCESSIBILITY_ZOOM_STORAGE_KEY = 'Accessibility:zoomLevel';
|
||||
export const ACCESSIBILITY_CUSTOM_THEME_STORAGE_KEY = 'Accessibility:customThemeCss';
|
||||
const ACCESSIBILITY_CUSTOM_THEME_SYNC_STORAGE_KEY = 'Accessibility:customThemeCssSyncAcrossDevices';
|
||||
export const ACCESSIBILITY_MOTION_STORAGE_KEY = 'Accessibility:motion';
|
||||
export const ACCESSIBILITY_SHOW_NEKO_STORAGE_KEY = 'Accessibility:showNeko';
|
||||
export const ACCESSIBILITY_KEEP_NEKO_STILL_STORAGE_KEY = 'Accessibility:keepNekoStill';
|
||||
@@ -492,6 +493,21 @@ function persistLocalCustomThemeCss(css: string | null): void {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function persistLocalCustomThemeCssSyncAcrossDevices(value: boolean): void {
|
||||
try {
|
||||
AppStorage.setItem(ACCESSIBILITY_CUSTOM_THEME_SYNC_STORAGE_KEY, JSON.stringify(value));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function readLocalCustomThemeCssSyncAcrossDevices(): boolean {
|
||||
try {
|
||||
const raw = AppStorage.getItem(ACCESSIBILITY_CUSTOM_THEME_SYNC_STORAGE_KEY);
|
||||
return raw === null ? false : JSON.parse(raw) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCustomThemeCss(css: string | null | undefined): string | null {
|
||||
if (typeof css !== 'string') {
|
||||
return null;
|
||||
@@ -728,6 +744,7 @@ class Accessibility {
|
||||
if (this.customThemeCss !== null) {
|
||||
persistLocalCustomThemeCss(this.customThemeCss);
|
||||
}
|
||||
this.customThemeCssSyncAcrossDevices = readLocalCustomThemeCssSyncAcrossDevices();
|
||||
this.showNeko = readAndMigrateLocalShowNeko();
|
||||
this.keepNekoStill = readAndMigrateLocalKeepNekoStill();
|
||||
this.showVideoSeekPreviewThumbnails = readLocalVideoSeekPreviewThumbnails();
|
||||
@@ -851,7 +868,11 @@ class Accessibility {
|
||||
showStickersInAutocomplete: s.showStickersInExpressionAutocomplete,
|
||||
showMemesInAutocomplete: s.showMemesInExpressionAutocomplete,
|
||||
voiceChannelJoinRequiresDoubleClick: s.voiceChannelJoinRequiresDoubleClick,
|
||||
customThemeCss: s.customThemeCssSyncAcrossDevices ? (s.customThemeCss ?? '') : (s.serverCustomThemeCss ?? ''),
|
||||
customThemeCss: ((): string | undefined => {
|
||||
const local = s.customThemeCss;
|
||||
const server = s.serverCustomThemeCss;
|
||||
return (s.customThemeCssSyncAcrossDevices ? local : server) ?? undefined;
|
||||
})(),
|
||||
showFavorites: s.showFavorites,
|
||||
dmMessagePreviewMode: DM_PREVIEW_TO_PROTO[s.dmMessagePreviewMode],
|
||||
enableTtsCommand: s.enableTTSCommand,
|
||||
@@ -1396,6 +1417,7 @@ class Accessibility {
|
||||
persistLocalCustomThemeCss(this.customThemeCss);
|
||||
}
|
||||
this.customThemeCssSyncAcrossDevices = syncAcrossDevices;
|
||||
persistLocalCustomThemeCssSyncAcrossDevices(syncAcrossDevices);
|
||||
}
|
||||
|
||||
subscribe(callback: () => void): () => void {
|
||||
|
||||
@@ -8,12 +8,11 @@ import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {buildAppProtocolUrl} from '@app/features/ui/utils/AppProtocol';
|
||||
import {isDesktop, openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
import {checkDesktopAvailable, navigateInDesktop} from '@app/features/voice/utils/DesktopRpcClient';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useState} from 'react';
|
||||
|
||||
interface DesktopDeepLinkPromptProps {
|
||||
code: string;
|
||||
@@ -36,25 +35,9 @@ const FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR = msg({
|
||||
export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({code, kind, preferLogin = false}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [desktopAvailable, setDesktopAvailable] = useState<boolean | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMobileBrowser = Platform.isMobileBrowser;
|
||||
const useProtocolLaunch = kind === 'invite';
|
||||
const shouldProbeDesktopAvailability = !useProtocolLaunch;
|
||||
useEffect(() => {
|
||||
if (isDesktop() || !shouldProbeDesktopAvailability) return;
|
||||
let cancelled = false;
|
||||
checkDesktopAvailable().then(({available}) => {
|
||||
if (!cancelled) {
|
||||
setDesktopAvailable(available);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [shouldProbeDesktopAvailability]);
|
||||
if (isDesktop() || isMobileBrowser) return null;
|
||||
if (shouldProbeDesktopAvailability && desktopAvailable !== true) return null;
|
||||
const getPath = (): string => {
|
||||
switch (kind) {
|
||||
case 'invite':
|
||||
@@ -69,20 +52,12 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
|
||||
const handleOpen = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
if (useProtocolLaunch) {
|
||||
try {
|
||||
await openExternalUrl(buildAppProtocolUrl(path));
|
||||
} catch {
|
||||
setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = await navigateInDesktop(path);
|
||||
setIsLoading(false);
|
||||
if (!result.success) {
|
||||
try {
|
||||
await openExternalUrl(buildAppProtocolUrl(path));
|
||||
} catch {
|
||||
setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -14,10 +14,11 @@ import {useVirtualRows} from '@app/features/channel/components/sticker_picker/ho
|
||||
import {StickerPickerCategoryList} from '@app/features/channel/components/sticker_picker/StickerPickerCategoryList';
|
||||
import {
|
||||
buildStickerRowOffsets,
|
||||
getStickerRowHeight,
|
||||
getFixedStickerRowHeight,
|
||||
getStickerGridColumns,
|
||||
getStickerRowWindow,
|
||||
STICKER_GRID_TRACK_WIDTH,
|
||||
STICKER_SECTION_GAP,
|
||||
STICKERS_PER_ROW,
|
||||
type StickerRowKind,
|
||||
} from '@app/features/channel/components/sticker_picker/StickerPickerConstants';
|
||||
import {StickerPickerInspector} from '@app/features/channel/components/sticker_picker/StickerPickerInspector';
|
||||
@@ -74,12 +75,12 @@ export const StickersPicker = observer(
|
||||
const [selectedColumn, setSelectedColumn] = useState(-1);
|
||||
const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false);
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const {scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
|
||||
const {viewportSize, scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const stickerRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null;
|
||||
const rowListRef = useRef<HTMLDivElement>(null);
|
||||
const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0, gridWidth: 0});
|
||||
const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0});
|
||||
const [stickerDataVersion, setStickerDataVersion] = useState(0);
|
||||
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
|
||||
const {shouldAnimate: shouldAnimateStickerPreview} = useStickerAnimation();
|
||||
@@ -138,13 +139,26 @@ export const StickersPicker = observer(
|
||||
allUpsell.accessibleItems,
|
||||
renderedStickers,
|
||||
);
|
||||
const zoomLevel = Accessibility.zoomLevel;
|
||||
const gridColumns = useMemo(() => getStickerGridColumns(viewportSize.width), [viewportSize.width, zoomLevel]);
|
||||
const previousGridColumnsRef = useRef(gridColumns);
|
||||
useLayoutEffect(() => {
|
||||
if (previousGridColumnsRef.current === gridColumns) {
|
||||
return;
|
||||
}
|
||||
previousGridColumnsRef.current = gridColumns;
|
||||
setHoveredSticker(null);
|
||||
setSelectedRow(-1);
|
||||
setSelectedColumn(-1);
|
||||
setShouldScrollOnSelection(false);
|
||||
}, [gridColumns]);
|
||||
const pickerRows = useVirtualRows(
|
||||
searchTerm,
|
||||
renderedStickers,
|
||||
favoriteStickers,
|
||||
frequentlyUsedStickers,
|
||||
stickersByGuildId,
|
||||
STICKERS_PER_ROW,
|
||||
gridColumns,
|
||||
);
|
||||
const hasNoStickersAtAll = allItems.length === 0;
|
||||
const isSearching = searchTerm.trim().length > 0;
|
||||
@@ -198,12 +212,8 @@ export const StickersPicker = observer(
|
||||
}
|
||||
return {stickerRowIndexes: indexes, stickerRowStarts: starts, categoryRowIndexes: categories};
|
||||
}, [pickerRows]);
|
||||
const zoomLevel = Accessibility.zoomLevel;
|
||||
const remScale = getAppRemScale();
|
||||
const stickerRowHeight = useMemo(
|
||||
() => getStickerRowHeight(listMetrics.gridWidth, STICKERS_PER_ROW, remScale),
|
||||
[listMetrics.gridWidth, remScale, zoomLevel],
|
||||
);
|
||||
const stickerRowHeight = useMemo(() => getFixedStickerRowHeight(remScale), [remScale, zoomLevel]);
|
||||
const rowOffsets = useMemo(() => {
|
||||
const rowKinds = pickerRows.map((row): StickerRowKind => row.type);
|
||||
return buildStickerRowOffsets(rowKinds, {remScale, stickerRowHeight, sectionGap: STICKER_SECTION_GAP});
|
||||
@@ -219,11 +229,8 @@ export const StickersPicker = observer(
|
||||
rowListNode.getBoundingClientRect().top - scrollerNode.getBoundingClientRect().top + scrollerNode.scrollTop,
|
||||
);
|
||||
const viewportHeight = scrollerNode.clientHeight;
|
||||
const gridWidth = rowListNode.clientWidth;
|
||||
setListMetrics((current) =>
|
||||
current.origin === origin && current.viewportHeight === viewportHeight && current.gridWidth === gridWidth
|
||||
? current
|
||||
: {origin, viewportHeight, gridWidth},
|
||||
current.origin === origin && current.viewportHeight === viewportHeight ? current : {origin, viewportHeight},
|
||||
);
|
||||
});
|
||||
const rowWindow = useMemo(
|
||||
@@ -394,7 +401,8 @@ export const StickersPicker = observer(
|
||||
row={row}
|
||||
handleHover={handleHover}
|
||||
handleSelect={handleStickerSelect}
|
||||
gridColumns={STICKERS_PER_ROW}
|
||||
gridColumns={gridColumns}
|
||||
cellTrackWidth={STICKER_GRID_TRACK_WIDTH}
|
||||
selectedRow={selectedRow}
|
||||
selectedColumn={selectedColumn}
|
||||
stickerRowIndex={stickerRowIndexes[rowIndex]!}
|
||||
|
||||
-7
@@ -278,10 +278,6 @@ const DEVELOPER_OPTION_DESCRIPTOR = msg({
|
||||
message: 'Developer option',
|
||||
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
|
||||
});
|
||||
const GAME_CAPTURE_INJECTION_METHOD_DESCRIPTOR = msg({
|
||||
message: 'Game capture injection (Windows)',
|
||||
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
|
||||
});
|
||||
const DEVELOPER_OPTION_LABEL_FALLBACKS: Partial<Record<keyof DeveloperOptionsState, MessageDescriptor>> = {
|
||||
mockAttachmentStates: ATTACHMENT_MOCKS_DESCRIPTOR,
|
||||
};
|
||||
@@ -327,7 +323,6 @@ const formatDeveloperOptionValue = <K extends keyof DeveloperOptionsState>(
|
||||
case 'mockRequiredActionsResendOutcome':
|
||||
case 'mockTitlebarPlatformOverride':
|
||||
case 'mockUpdaterState':
|
||||
case 'gameCaptureInjectionMethod':
|
||||
return String(value).replace(/_/g, ' ');
|
||||
case 'premiumSinceOverride':
|
||||
case 'premiumUntilOverride':
|
||||
@@ -410,8 +405,6 @@ export const getDeveloperOptionLabel = (key: keyof DeveloperOptionsState): Messa
|
||||
return VANITY_URL_DISCLAIMER_DESCRIPTOR;
|
||||
case 'forceShowVoiceConnection':
|
||||
return VOICE_CONNECTION_DEBUG_DESCRIPTOR;
|
||||
case 'gameCaptureInjectionMethod':
|
||||
return GAME_CAPTURE_INJECTION_METHOD_DESCRIPTOR;
|
||||
case 'premiumTypeOverride':
|
||||
return PREMIUM_TYPE_DESCRIPTOR;
|
||||
case 'premiumLifetimeSequenceOverride':
|
||||
|
||||
-13
@@ -1,15 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getDeveloperOptionLabel} from '@app/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels';
|
||||
import {DeveloperOptionRadioSubmenu} from '@app/features/channel/components/channel_header_components/developer_tools/DeveloperToolsMenuComponents';
|
||||
import {translateDescriptor} from '@app/features/channel/components/channel_header_components/developer_tools/DeveloperToolsShared';
|
||||
import {getGameCaptureInjectionMethodOptions} from '@app/features/channel/components/channel_header_components/developer_tools/OptionPresets';
|
||||
import {getToggleGroups, type ToggleGroup} from '@app/features/devtools/components/DeveloperOptionsToggleGroups';
|
||||
import type {DeveloperOptionsState} from '@app/features/devtools/state/DeveloperOptions';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import {CheckboxItem} from '@app/features/ui/action_menu/ContextMenu';
|
||||
import {MenuItemSubmenu} from '@app/features/ui/action_menu/MenuItemSubmenu';
|
||||
import {getNativePlatformSync} from '@app/features/ui/utils/NativeUtils';
|
||||
import * as UserSettingsCommands from '@app/features/user/commands/UserSettingsCommands';
|
||||
import UserSettings from '@app/features/user/state/UserSettings';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
@@ -71,7 +67,6 @@ export const GeneralDeveloperOptionsMenu: React.FC = observer(() => {
|
||||
const currentUser = Users.currentUser;
|
||||
const canConfigureMentionSuppression = currentUser?.isStaff() ?? false;
|
||||
const suppressUnprivilegedSelfMentions = UserSettings.getSuppressUnprivilegedSelfMentions();
|
||||
const isWindows = getNativePlatformSync() === 'windows';
|
||||
return (
|
||||
<>
|
||||
{toggleGroups.map((group, index) => (
|
||||
@@ -81,14 +76,6 @@ export const GeneralDeveloperOptionsMenu: React.FC = observer(() => {
|
||||
data-flx="channel.channel-header-components.developer-tools-context-menu.general-developer-options-menu.toggle-group-submenu"
|
||||
/>
|
||||
))}
|
||||
{isWindows && (
|
||||
<DeveloperOptionRadioSubmenu
|
||||
label={translateDescriptor(i18n, getDeveloperOptionLabel('gameCaptureInjectionMethod'))}
|
||||
optionKey="gameCaptureInjectionMethod"
|
||||
options={getGameCaptureInjectionMethodOptions()}
|
||||
data-flx="channel.channel-header-components.developer-tools-context-menu.general-developer-options-menu.game-capture-injection"
|
||||
/>
|
||||
)}
|
||||
{canConfigureMentionSuppression && (
|
||||
<MenuItemSubmenu
|
||||
label={i18n._(MENTION_CONTROLS_DESCRIPTOR)}
|
||||
|
||||
-11
@@ -196,14 +196,3 @@ export const getGiftDurationOptions = (): Array<RadioMenuOption<DeveloperOptions
|
||||
{value: 12, label: MESSAGE_12_MONTHS_1_YEAR_DESCRIPTOR},
|
||||
{value: 0, label: LIFETIME_DESCRIPTOR},
|
||||
];
|
||||
export const INJECT_METHOD_AUTOMATIC_DESCRIPTOR = msg({
|
||||
message: 'Automatic',
|
||||
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
|
||||
});
|
||||
export const getGameCaptureInjectionMethodOptions = (): Array<
|
||||
RadioMenuOption<DeveloperOptionsState['gameCaptureInjectionMethod']>
|
||||
> => [
|
||||
{value: 'auto', label: INJECT_METHOD_AUTOMATIC_DESCRIPTOR},
|
||||
{value: 'remote-thread', label: 'CreateRemoteThread'},
|
||||
{value: 'set-windows-hook', label: 'SetWindowsHookEx'},
|
||||
];
|
||||
|
||||
-1
@@ -69,7 +69,6 @@ export const DEFAULT_DEVELOPER_OPTIONS = {
|
||||
mockTitlebarPlatformOverride: 'auto',
|
||||
mockAttachmentStates: {},
|
||||
noOpInAppReports: false,
|
||||
gameCaptureInjectionMethod: 'auto',
|
||||
} satisfies DeveloperOptionsState;
|
||||
const PREMIUM_SCENARIO_OVERRIDE_KEYS = new Set<keyof DeveloperOptionsState>([
|
||||
'premiumTypeOverride',
|
||||
|
||||
@@ -125,7 +125,7 @@ export const AddFriendForm: React.FC<AddFriendFormProps> = observer(({onSuccess}
|
||||
};
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const [username, discriminator] = parseInput(input);
|
||||
const [username, discriminator] = parseInput(input['trim']());
|
||||
if (!username || !discriminator || !/^\d{4}$/.test(discriminator)) {
|
||||
setResultStatus('error');
|
||||
setErrorCode(APIErrorCodes.NO_USERS_WITH_FLUXERTAG_EXIST);
|
||||
|
||||
+2
-1
@@ -208,9 +208,10 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
|
||||
messageId: message?.id,
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
|
||||
});
|
||||
},
|
||||
[attachment, message, messageViewContext?.channel, mediaAttachments, shouldBlur],
|
||||
[attachment, message, messageViewContext?.channel, mediaAttachments, shouldBlur, isPreview, snapshotIndex],
|
||||
);
|
||||
const openInBrowser = useOpenInBrowserOnMiddleClick(attachment.url ?? attachment.proxy_url ?? '', !shouldBlur);
|
||||
const handleFavoriteClick = useCallback(
|
||||
|
||||
@@ -181,6 +181,7 @@ const useImagePreview = ({
|
||||
message,
|
||||
sourceChannel,
|
||||
providerName,
|
||||
allowAttachmentDelete = false,
|
||||
}: {
|
||||
proxyUrl: string;
|
||||
embedUrl: string;
|
||||
@@ -195,6 +196,7 @@ const useImagePreview = ({
|
||||
message?: Message;
|
||||
sourceChannel?: Channel | null;
|
||||
providerName?: string;
|
||||
allowAttachmentDelete?: boolean;
|
||||
}): {viewerItem: MediaViewerItem; openPreview: (event: React.MouseEvent | React.KeyboardEvent) => void} => {
|
||||
const viewerItem = useMemo<MediaViewerItem>(
|
||||
() => ({
|
||||
@@ -223,9 +225,10 @@ const useImagePreview = ({
|
||||
messageId,
|
||||
message,
|
||||
sourceChannel,
|
||||
allowAttachmentDelete,
|
||||
});
|
||||
},
|
||||
[viewerItem, channelId, messageId, message, sourceChannel],
|
||||
[viewerItem, channelId, messageId, message, sourceChannel, allowAttachmentDelete],
|
||||
);
|
||||
return {viewerItem, openPreview};
|
||||
};
|
||||
@@ -405,7 +408,7 @@ export const EmbedGifv: FC<
|
||||
},
|
||||
[visibilityRef],
|
||||
);
|
||||
const {dimensions, style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight});
|
||||
const {dimensions} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight});
|
||||
const canPlayInline = isInlinePlayableVideoSize({width: naturalWidth, height: naturalHeight});
|
||||
const posterSource = thumbnailProxyURL && thumbnailProxyURL.length > 0 ? thumbnailProxyURL : videoProxyURL;
|
||||
const posterURL = useMemo(() => {
|
||||
@@ -461,6 +464,7 @@ export const EmbedGifv: FC<
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
providerName,
|
||||
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
|
||||
});
|
||||
const handleDeleteClick = useDeleteAttachment(message, attachmentId);
|
||||
const handleDownloadClick = useCallback(
|
||||
@@ -530,8 +534,11 @@ export const EmbedGifv: FC<
|
||||
safePause(video);
|
||||
}, [shouldPlay]);
|
||||
if (shouldBlur) {
|
||||
const {width: _width, height: _height, ...styleWithoutDimensions} = style;
|
||||
const blurContainerStyle = {...styleWithoutDimensions, maxWidth: '100%', width: '100%'};
|
||||
const blurContainerStyle: React.CSSProperties = {
|
||||
maxWidth: '100%',
|
||||
width: remFromPx(dimensions.width),
|
||||
...aspectRatioStyle,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={visibilityRef}
|
||||
@@ -715,7 +722,7 @@ export const EmbedGif: FC<
|
||||
},
|
||||
[visibilityRef],
|
||||
);
|
||||
const {dimensions, style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight});
|
||||
const {dimensions} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight});
|
||||
const {width: displayWidth, height: displayHeight} = dimensions;
|
||||
const gifAutoPlay = useShouldAnimate({kind: 'gif'});
|
||||
const animationPolicyAllowed = useShouldAnimate({
|
||||
@@ -795,6 +802,7 @@ export const EmbedGif: FC<
|
||||
contentHash,
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
|
||||
});
|
||||
const {scheduleViewerWarm, cancelViewerWarm} = useMediaViewerHoverWarm(viewerItem, {
|
||||
allowAnimated: gifAutoPlay,
|
||||
@@ -851,8 +859,11 @@ export const EmbedGif: FC<
|
||||
],
|
||||
);
|
||||
if (shouldBlur) {
|
||||
const {width: _width, height: _height, ...styleWithoutDimensions} = style;
|
||||
const blurContainerStyle = {...styleWithoutDimensions, maxWidth: '100%', width: '100%'};
|
||||
const blurContainerStyle: React.CSSProperties = {
|
||||
maxWidth: '100%',
|
||||
width: remFromPx(dimensions.width),
|
||||
...aspectRatioStyle,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={visibilityRef}
|
||||
|
||||
@@ -104,6 +104,7 @@ interface ImagePreviewHandlerProps {
|
||||
message?: Message;
|
||||
animated?: boolean;
|
||||
mediaAttachments?: ReadonlyArray<MessageAttachment>;
|
||||
allowAttachmentDelete?: boolean;
|
||||
onViewerWarmEnter?: () => void;
|
||||
onViewerWarmLeave?: () => void;
|
||||
children: React.ReactNode;
|
||||
@@ -143,6 +144,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
message,
|
||||
animated,
|
||||
mediaAttachments = NO_MEDIA_ATTACHMENTS,
|
||||
allowAttachmentDelete = false,
|
||||
onViewerWarmEnter,
|
||||
onViewerWarmLeave,
|
||||
children,
|
||||
@@ -176,6 +178,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
messageId,
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
allowAttachmentDelete,
|
||||
});
|
||||
} else {
|
||||
MediaViewerCommands.openMediaViewer(
|
||||
@@ -187,6 +190,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
naturalHeight,
|
||||
type: 'image' as const,
|
||||
contentHash,
|
||||
attachmentId,
|
||||
embedIndex,
|
||||
expiresAt: undefined,
|
||||
expired: undefined,
|
||||
@@ -199,6 +203,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
messageId,
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
allowAttachmentDelete,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -217,6 +222,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
message,
|
||||
messageViewContext?.channel,
|
||||
mediaAttachments,
|
||||
allowAttachmentDelete,
|
||||
],
|
||||
);
|
||||
const openInBrowser = useOpenInBrowserOnMiddleClick(originalSrc || src);
|
||||
@@ -381,6 +387,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
|
||||
[originalSrc, src],
|
||||
);
|
||||
const handleDeleteClick = useDeleteAttachment(message, attachmentId);
|
||||
const allowAttachmentDelete = !isPreview && snapshotIndex === undefined;
|
||||
const [mediaSheetOpen, setMediaSheetOpen] = useState(false);
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -524,6 +531,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
|
||||
attachmentId={attachmentId}
|
||||
message={message}
|
||||
mediaAttachments={mediaAttachments}
|
||||
allowAttachmentDelete={allowAttachmentDelete}
|
||||
animated={animated}
|
||||
onViewerWarmEnter={scheduleViewerWarm}
|
||||
onViewerWarmLeave={cancelViewerWarm}
|
||||
|
||||
@@ -272,6 +272,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
[src],
|
||||
);
|
||||
const handleDeleteClick = useDeleteAttachment(message, attachmentId);
|
||||
const allowAttachmentDelete = !isPreview && snapshotIndex === undefined;
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!message) return;
|
||||
@@ -396,6 +397,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
messageId,
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
allowAttachmentDelete,
|
||||
});
|
||||
} else {
|
||||
MediaViewerCommands.openMediaViewer(
|
||||
@@ -407,12 +409,13 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
naturalHeight: viewerVideoDimensions.height,
|
||||
type: 'video' as const,
|
||||
contentHash,
|
||||
attachmentId,
|
||||
embedIndex,
|
||||
duration,
|
||||
},
|
||||
],
|
||||
0,
|
||||
{channelId, messageId, message, sourceChannel: messageViewContext?.channel},
|
||||
{channelId, messageId, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete},
|
||||
);
|
||||
}
|
||||
}, [
|
||||
@@ -420,6 +423,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
messageId,
|
||||
message,
|
||||
messageViewContext?.channel,
|
||||
allowAttachmentDelete,
|
||||
mediaAttachments,
|
||||
attachmentId,
|
||||
effectiveSrc,
|
||||
@@ -451,6 +455,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
messageId,
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
allowAttachmentDelete,
|
||||
});
|
||||
} else {
|
||||
MediaViewerCommands.openMediaViewer(
|
||||
@@ -462,13 +467,14 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
naturalHeight: viewerVideoDimensions.height,
|
||||
type: 'video' as const,
|
||||
contentHash,
|
||||
attachmentId,
|
||||
embedIndex,
|
||||
duration,
|
||||
initialTime: currentTime,
|
||||
},
|
||||
],
|
||||
0,
|
||||
{channelId, messageId, message, sourceChannel: messageViewContext?.channel},
|
||||
{channelId, messageId, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete},
|
||||
);
|
||||
}
|
||||
setIsPlayingInline(false);
|
||||
@@ -477,6 +483,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
messageId,
|
||||
message,
|
||||
messageViewContext?.channel,
|
||||
allowAttachmentDelete,
|
||||
mediaAttachments,
|
||||
attachmentId,
|
||||
effectiveSrc,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx';
|
||||
|
||||
export const STICKERS_PER_ROW = 4;
|
||||
export const STICKERS_PER_ROW_MOBILE = 5;
|
||||
export const STICKER_CATEGORY_HEADER_HEIGHT = 32;
|
||||
export const STICKER_SECTION_GAP = 12;
|
||||
export const STICKER_GRID_TRACK_WIDTH = 120;
|
||||
|
||||
const STICKER_CATEGORY_HEADER_GAP = 8;
|
||||
const STICKER_GRID_GAP = 8;
|
||||
@@ -23,6 +26,20 @@ export interface StickerRowWindow {
|
||||
lastRow: number;
|
||||
}
|
||||
|
||||
export function getStickerGridColumns(containerWidth: number): number {
|
||||
if (containerWidth <= 0) {
|
||||
return STICKERS_PER_ROW;
|
||||
}
|
||||
const remScale = getRemScaleForDocument(typeof document === 'undefined' ? null : document);
|
||||
const trackWidth = STICKER_GRID_TRACK_WIDTH * remScale;
|
||||
const gap = STICKER_GRID_GAP * remScale;
|
||||
return Math.max(1, Math.floor((containerWidth + gap) / (trackWidth + gap)));
|
||||
}
|
||||
|
||||
export function getFixedStickerRowHeight(remScale: number): number {
|
||||
return (STICKER_GRID_TRACK_WIDTH + STICKER_GRID_BLOCK_END_PADDING) * remScale;
|
||||
}
|
||||
|
||||
export function getStickerRowHeight(gridWidth: number, gridColumns: number, remScale: number): number {
|
||||
if (!(gridWidth > 0) || gridColumns <= 0) {
|
||||
return 0;
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
padding: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.stickerGridFixedTrack {
|
||||
grid-auto-flow: column;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.stickerButton {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -27,6 +27,7 @@ interface VirtualRowRendererProps {
|
||||
handleHover: (sticker: GuildSticker | null, row?: number, column?: number) => void;
|
||||
handleSelect: (sticker: GuildSticker, shiftKey?: boolean) => void;
|
||||
gridColumns?: number;
|
||||
cellTrackWidth?: number;
|
||||
selectedRow: number;
|
||||
selectedColumn: number;
|
||||
stickerRowIndex: number;
|
||||
@@ -126,6 +127,7 @@ const VirtualRowRendererBase: React.FC<VirtualRowRendererProps> = React.memo(
|
||||
handleHover,
|
||||
handleSelect,
|
||||
gridColumns = 4,
|
||||
cellTrackWidth,
|
||||
selectedRow,
|
||||
selectedColumn,
|
||||
stickerRowIndex,
|
||||
@@ -220,11 +222,14 @@ const VirtualRowRendererBase: React.FC<VirtualRowRendererProps> = React.memo(
|
||||
);
|
||||
}
|
||||
if (row.type === 'sticker-row') {
|
||||
const hasFixedTrack = cellTrackWidth != null && cellTrackWidth > 0;
|
||||
return (
|
||||
<div
|
||||
className={styles.stickerGrid}
|
||||
className={hasFixedTrack ? `${styles.stickerGrid} ${styles.stickerGridFixedTrack}` : styles.stickerGrid}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: hasFixedTrack
|
||||
? `repeat(auto-fill, ${remFromPx(cellTrackWidth)})`
|
||||
: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
data-flx="channel.sticker-picker.virtual-row.virtual-row-renderer-base.sticker-grid"
|
||||
>
|
||||
@@ -286,6 +291,7 @@ interface VirtualRowWrapperProps {
|
||||
handleHover: (sticker: GuildSticker | null, row?: number, column?: number) => void;
|
||||
handleSelect: (sticker: GuildSticker, shiftKey?: boolean) => void;
|
||||
gridColumns?: number;
|
||||
cellTrackWidth?: number;
|
||||
selectedRow: number;
|
||||
selectedColumn: number;
|
||||
stickerRowIndex: number;
|
||||
@@ -300,6 +306,7 @@ export const VirtualRowWrapper: React.FC<VirtualRowWrapperProps> = observer(
|
||||
handleHover,
|
||||
handleSelect,
|
||||
gridColumns,
|
||||
cellTrackWidth,
|
||||
selectedRow,
|
||||
selectedColumn,
|
||||
stickerRowIndex,
|
||||
@@ -313,6 +320,7 @@ export const VirtualRowWrapper: React.FC<VirtualRowWrapperProps> = observer(
|
||||
handleHover={handleHover}
|
||||
handleSelect={handleSelect}
|
||||
gridColumns={gridColumns}
|
||||
cellTrackWidth={cellTrackWidth}
|
||||
selectedRow={selectedRow}
|
||||
selectedColumn={selectedColumn}
|
||||
stickerRowIndex={stickerRowIndex}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user