refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,124 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IAssetDeletionQueue, QueuedAssetDeletion} from '@fluxer/api/src/infrastructure/IAssetDeletionQueue';
import {Logger} from '@fluxer/api/src/Logger';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
const QUEUE_KEY = 'asset:deletion:queue';
const MAX_RETRIES = 5;
export class AssetDeletionQueue implements IAssetDeletionQueue {
constructor(private readonly kvClient: IKVProvider) {}
async queueDeletion(item: Omit<QueuedAssetDeletion, 'queuedAt' | 'retryCount'>): Promise<void> {
const fullItem: QueuedAssetDeletion = {
...item,
queuedAt: Date.now(),
retryCount: 0,
};
try {
await this.kvClient.rpush(QUEUE_KEY, JSON.stringify(fullItem));
Logger.debug({s3Key: item.s3Key, reason: item.reason}, 'Queued asset for deletion');
} catch (error) {
Logger.error({error, item}, 'Failed to queue asset for deletion');
throw error;
}
}
async queueCdnPurge(cdnUrl: string): Promise<void> {
const item: QueuedAssetDeletion = {
s3Key: '',
cdnUrl,
reason: 'cdn_purge_only',
queuedAt: Date.now(),
retryCount: 0,
};
try {
await this.kvClient.rpush(QUEUE_KEY, JSON.stringify(item));
Logger.debug({cdnUrl}, 'Queued CDN URL for purge');
} catch (error) {
Logger.error({error, cdnUrl}, 'Failed to queue CDN URL for purge');
throw error;
}
}
async getBatch(count: number): Promise<Array<QueuedAssetDeletion>> {
if (count <= 0) {
return [];
}
try {
const items = await this.kvClient.lpop(QUEUE_KEY, count);
if (items.length === 0) {
return [];
}
return items.map((item) => JSON.parse(item) as QueuedAssetDeletion);
} catch (error) {
Logger.error({error, count}, 'Failed to get batch from asset deletion queue');
throw error;
}
}
async requeueItem(item: QueuedAssetDeletion): Promise<void> {
const retryCount = (item.retryCount ?? 0) + 1;
if (retryCount > MAX_RETRIES) {
Logger.error(
{s3Key: item.s3Key, cdnUrl: item.cdnUrl, retryCount},
'Asset deletion exceeded max retries, dropping from queue',
);
return;
}
const requeuedItem: QueuedAssetDeletion = {
...item,
retryCount,
};
try {
await this.kvClient.rpush(QUEUE_KEY, JSON.stringify(requeuedItem));
Logger.debug({s3Key: item.s3Key, retryCount}, 'Requeued failed asset deletion');
} catch (error) {
Logger.error({error, item}, 'Failed to requeue asset deletion');
throw error;
}
}
async getQueueSize(): Promise<number> {
try {
return await this.kvClient.llen(QUEUE_KEY);
} catch (error) {
Logger.error({error}, 'Failed to get asset deletion queue size');
throw error;
}
}
async clear(): Promise<void> {
try {
await this.kvClient.del(QUEUE_KEY);
Logger.debug('Cleared asset deletion queue');
} catch (error) {
Logger.error({error}, 'Failed to clear asset deletion queue');
throw error;
}
}
}

View File

@@ -0,0 +1,432 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import {Config} from '@fluxer/api/src/Config';
import type {CsamResourceType} from '@fluxer/api/src/csam/CsamTypes';
import type {ICsamReportSnapshotService} from '@fluxer/api/src/csam/ICsamReportSnapshotService';
import type {ISynchronousCsamScanner} from '@fluxer/api/src/csam/ISynchronousCsamScanner';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import {Logger} from '@fluxer/api/src/Logger';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {
AVATAR_EXTENSIONS,
AVATAR_MAX_SIZE,
EMOJI_EXTENSIONS,
EMOJI_MAX_SIZE,
STICKER_EXTENSIONS,
STICKER_MAX_SIZE,
} from '@fluxer/constants/src/LimitConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
export interface CsamUploadContext {
userId?: string;
guildId?: string;
channelId?: string;
messageId?: string;
}
type LimitConfigSnapshotProvider = Pick<LimitConfigService, 'getConfigSnapshot'>;
export class AvatarService {
private readonly synchronousCsamScanner?: ISynchronousCsamScanner;
private readonly csamReportSnapshotService?: ICsamReportSnapshotService;
constructor(
private storageService: IStorageService,
private mediaService: IMediaService,
private limitConfigService: LimitConfigSnapshotProvider,
synchronousCsamScanner?: ISynchronousCsamScanner,
csamReportSnapshotService?: ICsamReportSnapshotService,
) {
this.synchronousCsamScanner = synchronousCsamScanner;
this.csamReportSnapshotService = csamReportSnapshotService;
}
private resolveSizeLimit(key: LimitKey, fallback: number): number {
const ctx = createLimitMatchContext({user: null});
const resolved = resolveLimit(this.limitConfigService.getConfigSnapshot(), ctx, key);
if (!Number.isFinite(resolved) || resolved < 0) {
return fallback;
}
return Math.floor(resolved);
}
async uploadAvatar(params: {
prefix: 'avatars' | 'icons' | 'banners' | 'splashes';
entityId?: bigint;
keyPath?: string;
errorPath: string;
previousKey?: string | null;
base64Image?: string | null;
csamContext?: CsamUploadContext;
}): Promise<string | null> {
const {prefix, entityId, keyPath, errorPath, previousKey, base64Image, csamContext} = params;
const fullKeyPath = keyPath ?? (entityId ? entityId.toString() : '');
if (!base64Image) {
if (previousKey) {
await this.storageService.deleteAvatar({
prefix,
key: `${fullKeyPath}/${this.stripAnimationPrefix(previousKey)}`,
});
}
return null;
}
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
let imageBuffer: Uint8Array;
try {
imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
} catch {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.INVALID_IMAGE_DATA);
}
const maxAvatarSize = this.resolveSizeLimit('avatar_max_size', AVATAR_MAX_SIZE);
if (imageBuffer.length > maxAvatarSize) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
maxSize: maxAvatarSize,
});
}
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: base64Data,
isNSFWAllowed: false,
});
if (metadata == null || !AVATAR_EXTENSIONS.has(metadata.format)) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.INVALID_IMAGE_FORMAT, {
supportedExtensions: this.formatSupportedExtensions(AVATAR_EXTENSIONS),
});
}
const imageHash = crypto.createHash('md5').update(Buffer.from(imageBuffer)).digest('hex');
const imageHashShort = imageHash.slice(0, 8);
const isAnimatedAvatar = metadata.animated ?? false;
const storedHash = isAnimatedAvatar ? `a_${imageHashShort}` : imageHashShort;
const label = fullKeyPath ? `${prefix}-${fullKeyPath}-${storedHash}` : `${prefix}-${storedHash}`;
await this.scanAndBlockCsam({
base64Data,
contentType: metadata.content_type,
imageBuffer,
resourceType: this.getResourceTypeForPrefix(prefix),
filename: label,
csamContext,
});
await this.storageService.uploadAvatar({prefix, key: `${fullKeyPath}/${imageHashShort}`, body: imageBuffer});
if (previousKey) {
await this.storageService.deleteAvatar({
prefix,
key: `${fullKeyPath}/${this.stripAnimationPrefix(previousKey)}`,
});
}
return storedHash;
}
async uploadAvatarToPath(params: {
bucket: string;
keyPath: string;
errorPath: string;
previousKey?: string | null;
base64Image?: string | null;
csamContext?: CsamUploadContext;
}): Promise<string | null> {
const {bucket, keyPath, errorPath, previousKey, base64Image, csamContext} = params;
const stripAnimationPrefix = (key: string) => (key.startsWith('a_') ? key.substring(2) : key);
if (!base64Image) {
if (previousKey) {
await this.storageService.deleteObject(bucket, `${keyPath}/${stripAnimationPrefix(previousKey)}`);
}
return null;
}
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
let imageBuffer: Uint8Array;
try {
imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
} catch {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.INVALID_IMAGE_DATA);
}
const maxAvatarSize = this.resolveSizeLimit('avatar_max_size', AVATAR_MAX_SIZE);
if (imageBuffer.length > maxAvatarSize) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
maxSize: maxAvatarSize,
});
}
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: base64Data,
isNSFWAllowed: false,
});
if (metadata == null || !AVATAR_EXTENSIONS.has(metadata.format)) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.INVALID_IMAGE_FORMAT, {
supportedExtensions: this.formatSupportedExtensions(AVATAR_EXTENSIONS),
});
}
const imageHash = crypto.createHash('md5').update(Buffer.from(imageBuffer)).digest('hex');
const imageHashShort = imageHash.slice(0, 8);
const isAnimatedAvatar = metadata.animated ?? false;
const storedHash = isAnimatedAvatar ? `a_${imageHashShort}` : imageHashShort;
const label = `${keyPath}-${storedHash}`;
await this.scanAndBlockCsam({
base64Data,
contentType: metadata.content_type,
imageBuffer,
resourceType: 'other',
filename: label,
csamContext,
});
await this.storageService.uploadObject({
bucket,
key: `${keyPath}/${imageHashShort}`,
body: imageBuffer,
});
if (previousKey) {
await this.storageService.deleteObject(bucket, `${keyPath}/${stripAnimationPrefix(previousKey)}`);
}
return storedHash;
}
async processEmoji(params: {errorPath: string; base64Image: string}): Promise<{
imageBuffer: Uint8Array;
animated: boolean;
format: string;
contentType: string;
}> {
const {errorPath, base64Image} = params;
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
let imageBuffer: Uint8Array;
try {
imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
} catch {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.INVALID_IMAGE_DATA);
}
const maxEmojiSize = this.resolveSizeLimit('emoji_max_size', EMOJI_MAX_SIZE);
if (imageBuffer.length > maxEmojiSize) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
maxSize: maxEmojiSize,
});
}
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: base64Data,
isNSFWAllowed: false,
});
if (metadata == null || !EMOJI_EXTENSIONS.has(metadata.format)) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.INVALID_IMAGE_FORMAT, {
supportedExtensions: this.formatSupportedExtensions(EMOJI_EXTENSIONS),
});
}
const animated = metadata.animated ?? false;
return {imageBuffer, animated, format: metadata.format, contentType: metadata.content_type};
}
async uploadEmoji(params: {
prefix: 'emojis';
emojiId: bigint;
imageBuffer: Uint8Array;
contentType?: string | null;
csamContext?: CsamUploadContext;
}): Promise<void> {
const {prefix, emojiId, imageBuffer, contentType, csamContext} = params;
const base64Data = Buffer.from(imageBuffer).toString('base64');
const label = `${prefix}-${emojiId}`;
await this.scanAndBlockCsam({
base64Data,
contentType: contentType ?? 'image/png',
imageBuffer,
resourceType: 'emoji',
filename: label,
csamContext,
});
await this.storageService.uploadAvatar({prefix, key: emojiId.toString(), body: imageBuffer});
}
async processSticker(params: {errorPath: string; base64Image: string}): Promise<{
imageBuffer: Uint8Array;
animated: boolean;
format: string;
contentType: string;
}> {
const {errorPath, base64Image} = params;
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
let imageBuffer: Uint8Array;
try {
imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
} catch {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.INVALID_IMAGE_DATA);
}
const maxStickerSize = this.resolveSizeLimit('sticker_max_size', STICKER_MAX_SIZE);
if (imageBuffer.length > maxStickerSize) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
maxSize: maxStickerSize,
});
}
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: base64Data,
isNSFWAllowed: false,
});
if (metadata == null || !STICKER_EXTENSIONS.has(metadata.format)) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.INVALID_IMAGE_FORMAT, {
supportedExtensions: this.formatSupportedExtensions(STICKER_EXTENSIONS),
});
}
const animated = metadata.animated ?? false;
return {imageBuffer, animated, format: metadata.format, contentType: metadata.content_type};
}
async uploadSticker(params: {
prefix: 'stickers';
stickerId: bigint;
imageBuffer: Uint8Array;
contentType?: string | null;
csamContext?: CsamUploadContext;
}): Promise<void> {
const {prefix, stickerId, imageBuffer, contentType, csamContext} = params;
const base64Data = Buffer.from(imageBuffer).toString('base64');
const label = `${prefix}-${stickerId}`;
await this.scanAndBlockCsam({
base64Data,
contentType: contentType ?? 'image/png',
imageBuffer,
resourceType: 'sticker',
filename: label,
csamContext,
});
await this.storageService.uploadAvatar({prefix, key: stickerId.toString(), body: imageBuffer});
}
async checkStickerAnimated(stickerId: bigint): Promise<boolean | null> {
try {
const metadata = await this.mediaService.getMetadata({
type: 's3',
bucket: Config.s3.buckets.cdn,
key: `stickers/${stickerId}`,
isNSFWAllowed: false,
});
return metadata?.animated ?? null;
} catch (_error) {
Logger.warn({stickerId}, 'Failed to check sticker animation status');
return null;
}
}
private async scanAndBlockCsam(params: {
base64Data: string;
contentType: string;
imageBuffer: Uint8Array;
resourceType: CsamResourceType;
filename: string;
csamContext?: CsamUploadContext;
}): Promise<void> {
if (!this.synchronousCsamScanner) {
return;
}
const scanResult = await this.synchronousCsamScanner.scanBase64({
base64: params.base64Data,
mimeType: params.contentType,
});
if (scanResult.isMatch && scanResult.matchResult && this.csamReportSnapshotService) {
await this.csamReportSnapshotService.createSnapshot({
scanResult: scanResult.matchResult,
resourceType: params.resourceType,
userId: params.csamContext?.userId ?? null,
guildId: params.csamContext?.guildId ?? null,
channelId: params.csamContext?.channelId ?? null,
messageId: params.csamContext?.messageId ?? null,
mediaData: Buffer.from(params.imageBuffer),
filename: params.filename,
contentType: params.contentType,
});
throw new ContentBlockedError();
}
}
private getResourceTypeForPrefix(prefix: string): CsamResourceType {
switch (prefix) {
case 'avatars':
case 'icons':
return 'avatar';
case 'banners':
case 'splashes':
case 'embed-splashes':
return 'banner';
case 'emojis':
return 'emoji';
case 'stickers':
return 'sticker';
default:
return 'other';
}
}
private stripAnimationPrefix(hash: string): string {
return hash.startsWith('a_') ? hash.substring(2) : hash;
}
private formatSupportedExtensions(extSet: ReadonlySet<string>): string {
return [...extSet].join(', ');
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs/promises';
import {createConnection} from 'node:net';
import {Config} from '@fluxer/api/src/Config';
interface ScanResult {
isClean: boolean;
virus?: string;
}
export class ClamAV {
constructor(
private host: string = Config.clamav.host,
private port: number = Config.clamav.port,
) {}
async scanFile(filePath: string): Promise<ScanResult> {
const buffer = await fs.readFile(filePath);
return this.scanBuffer(buffer);
}
async scanBuffer(buffer: Buffer): Promise<ScanResult> {
return new Promise((resolve, reject) => {
const socket = createConnection(this.port, this.host);
let response = '';
let isResolved = false;
const cleanup = () => {
if (!socket.destroyed) {
socket.destroy();
}
};
const doReject = (error: Error) => {
if (isResolved) return;
isResolved = true;
cleanup();
reject(error);
};
const doResolve = (result: ScanResult) => {
if (isResolved) return;
isResolved = true;
cleanup();
resolve(result);
};
socket.on('connect', () => {
try {
socket.write('zINSTREAM\0');
const chunkSize = Math.min(buffer.length, 2048);
let offset = 0;
while (offset < buffer.length) {
const remainingBytes = buffer.length - offset;
const bytesToSend = Math.min(chunkSize, remainingBytes);
const sizeBuffer = Buffer.alloc(4);
sizeBuffer.writeUInt32BE(bytesToSend, 0);
socket.write(sizeBuffer);
socket.write(buffer.subarray(offset, offset + bytesToSend));
offset += bytesToSend;
}
const endBuffer = Buffer.alloc(4);
endBuffer.writeUInt32BE(0, 0);
socket.write(endBuffer);
} catch (error) {
doReject(new Error(`ClamAV write failed: ${error instanceof Error ? error.message : String(error)}`));
}
});
socket.on('data', (data) => {
response += data.toString();
});
socket.on('end', () => {
const trimmedResponse = response.trim();
if (trimmedResponse.includes('FOUND')) {
const virusMatch = trimmedResponse.match(/:\s(.+)\sFOUND/);
const virus = virusMatch ? virusMatch[1] : 'Virus detected';
doResolve({
isClean: false,
virus,
});
} else if (trimmedResponse.includes('OK')) {
doResolve({
isClean: true,
});
} else {
doReject(new Error(`Unexpected ClamAV response: ${trimmedResponse}`));
}
});
socket.on('error', (error) => {
doReject(new Error(`ClamAV connection failed: ${error.message}`));
});
});
}
}

View File

@@ -0,0 +1,202 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@fluxer/api/src/Logger';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
export interface IPurgeQueue {
addUrls(urls: Array<string>): Promise<void>;
getBatch(count: number): Promise<Array<string>>;
getQueueSize(): Promise<number>;
clear(): Promise<void>;
tryConsumeTokens(
requestedTokens: number,
maxTokens: number,
refillRate: number,
refillIntervalMs: number,
): Promise<number>;
getAvailableTokens(maxTokens: number, refillRate: number, refillIntervalMs: number): Promise<number>;
}
export class CloudflarePurgeQueue implements IPurgeQueue {
private readonly kvClient: IKVProvider;
private readonly queueKey = 'cloudflare:purge:urls';
private readonly tokenBucketKey = 'cloudflare:purge:token_bucket';
constructor(kvClient: IKVProvider) {
this.kvClient = kvClient;
}
async addUrls(urls: Array<string>): Promise<void> {
if (urls.length === 0) {
return;
}
const normalized = Array.from(
new Set(urls.map((url) => this.normalizePrefix(url)).filter((prefix) => prefix !== '')),
);
if (normalized.length === 0) {
return;
}
try {
await this.kvClient.sadd(this.queueKey, ...normalized);
Logger.debug({count: normalized.length}, 'Added prefixes to Cloudflare purge queue');
} catch (error) {
Logger.error({error, urls}, 'Failed to add prefixes to Cloudflare purge queue');
throw error;
}
}
private normalizePrefix(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (trimmed === '') {
return '';
}
try {
const parsed = new URL(trimmed);
return `${parsed.host}${parsed.pathname}`;
} catch {
const [withoutQuery = ''] = trimmed.split(/[?#]/);
return withoutQuery.replace(/^https?:\/\//, '');
}
}
async getBatch(count: number): Promise<Array<string>> {
if (count <= 0) {
return [];
}
try {
const urls = await this.kvClient.spop(this.queueKey, count);
return Array.isArray(urls) ? urls : urls ? [urls] : [];
} catch (error) {
Logger.error({error, count}, 'Failed to get batch from Cloudflare purge queue');
throw error;
}
}
async getQueueSize(): Promise<number> {
try {
return await this.kvClient.scard(this.queueKey);
} catch (error) {
Logger.error({error}, 'Failed to get Cloudflare purge queue size');
throw error;
}
}
async clear(): Promise<void> {
try {
await this.kvClient.del(this.queueKey);
Logger.debug('Cleared Cloudflare purge queue');
} catch (error) {
Logger.error({error}, 'Failed to clear Cloudflare purge queue');
throw error;
}
}
async tryConsumeTokens(
requestedTokens: number,
maxTokens: number,
refillRate: number,
refillIntervalMs: number,
): Promise<number> {
try {
const consumed = await this.kvClient.tryConsumeTokens(
this.tokenBucketKey,
requestedTokens,
maxTokens,
refillRate,
refillIntervalMs,
);
Logger.debug({requested: requestedTokens, consumed}, 'Tried to consume tokens from bucket');
return consumed;
} catch (error) {
Logger.error({error, requestedTokens}, 'Failed to consume tokens');
throw error;
}
}
async getAvailableTokens(maxTokens: number, refillRate: number, refillIntervalMs: number): Promise<number> {
try {
const now = Date.now();
const bucketData = await this.kvClient.get(this.tokenBucketKey);
if (!bucketData) {
return maxTokens;
}
const parsed = JSON.parse(bucketData);
let tokens = parsed.tokens;
const lastRefill = parsed.lastRefill;
const elapsed = now - lastRefill;
const tokensToAdd = Math.floor(elapsed / refillIntervalMs) * refillRate;
if (tokensToAdd > 0) {
tokens = Math.min(maxTokens, tokens + tokensToAdd);
}
return tokens;
} catch (error) {
Logger.error({error}, 'Failed to get available tokens');
throw error;
}
}
async resetTokenBucket(): Promise<void> {
try {
await this.kvClient.del(this.tokenBucketKey);
Logger.debug('Reset Cloudflare purge token bucket');
} catch (error) {
Logger.error({error}, 'Failed to reset Cloudflare purge token bucket');
throw error;
}
}
}
export class NoopPurgeQueue implements IPurgeQueue {
async addUrls(_urls: Array<string>): Promise<void> {}
async getBatch(_count: number): Promise<Array<string>> {
return [];
}
async getQueueSize(): Promise<number> {
return 0;
}
async clear(): Promise<void> {}
async tryConsumeTokens(
_requestedTokens: number,
_maxTokens: number,
_refillRate: number,
_refillIntervalMs: number,
): Promise<number> {
return 0;
}
async getAvailableTokens(maxTokens: number, _refillRate: number, _refillIntervalMs: number): Promise<number> {
return maxTokens;
}
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
IMediaService,
type MediaProxyFrameRequest,
type MediaProxyFrameResponse,
type MediaProxyMetadataRequest,
type MediaProxyMetadataResponse,
} from '@fluxer/api/src/infrastructure/IMediaService';
import {ExplicitContentCannotBeSentError} from '@fluxer/errors/src/domains/moderation/ExplicitContentCannotBeSentError';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {IFrameService, IMetadataService} from '@fluxer/media_proxy/src/types/MediaProxyServices';
import {
createExternalMediaProxyUrlBuilder,
type IExternalMediaProxyUrlBuilder,
} from '@fluxer/media_proxy_utils/src/ExternalMediaProxyUrlBuilder';
export interface DirectMediaServiceOptions {
metadataService: IMetadataService;
frameService: IFrameService;
mediaProxyEndpoint: string;
mediaProxySecretKey: string;
logger: LoggerInterface;
}
export class DirectMediaService extends IMediaService {
private readonly metadataService: IMetadataService;
private readonly frameService: IFrameService;
private readonly proxyURL: URL;
private readonly logger: LoggerInterface;
private readonly externalMediaProxyUrlBuilder: IExternalMediaProxyUrlBuilder;
constructor(options: DirectMediaServiceOptions) {
super();
this.metadataService = options.metadataService;
this.frameService = options.frameService;
this.proxyURL = new URL(options.mediaProxyEndpoint);
this.logger = options.logger;
this.externalMediaProxyUrlBuilder = createExternalMediaProxyUrlBuilder({
mediaProxyEndpoint: options.mediaProxyEndpoint,
mediaProxySecretKey: options.mediaProxySecretKey,
});
}
async getMetadata(request: MediaProxyMetadataRequest): Promise<MediaProxyMetadataResponse | null> {
try {
const metadata = await this.metadataService.getMetadata(request);
if (!request.isNSFWAllowed && metadata.nsfw) {
throw new ExplicitContentCannotBeSentError(metadata.nsfw_probability ?? 0, metadata.nsfw_predictions ?? {});
}
return {
...metadata,
format: metadata.format.toLowerCase(),
};
} catch (error) {
if (error instanceof ExplicitContentCannotBeSentError) {
throw error;
}
this.logger.error({error}, 'Failed to get media metadata directly');
return null;
}
}
getExternalMediaProxyURL(url: string): string {
let urlObj: URL;
try {
urlObj = new URL(url);
} catch (_e) {
return this.handleExternalURL(url);
}
if (urlObj.host === this.proxyURL.host) {
return url;
}
return this.handleExternalURL(url);
}
async getThumbnail(uploadFilename: string): Promise<Buffer | null> {
try {
return await this.metadataService.getThumbnail({upload_filename: uploadFilename});
} catch (error) {
this.logger.error({error, uploadFilename}, 'Failed to get thumbnail directly');
return null;
}
}
async extractFrames(request: MediaProxyFrameRequest): Promise<MediaProxyFrameResponse> {
return await this.frameService.extractFrames(request);
}
private handleExternalURL(url: string): string {
return this.externalMediaProxyUrlBuilder.buildExternalMediaProxyUrl(url);
}
}

View File

@@ -0,0 +1,235 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs';
import path from 'node:path';
import {Config} from '@fluxer/api/src/Config';
import {encodeKey} from '@fluxer/api/src/database/SqliteKV';
import {
type DirectS3ExpirationEntry,
DirectS3ExpirationStore,
} from '@fluxer/api/src/infrastructure/DirectS3ExpirationStore';
import {Logger} from '@fluxer/api/src/Logger';
import {
DIRECT_S3_EXPIRATION_DB_FILENAME,
DIRECT_S3_EXPIRATION_RETRY_DELAY_MS,
DIRECT_S3_EXPIRATION_TIMER_MAX_DELAY_MS,
} from '@fluxer/constants/src/StorageConstants';
import type {S3Service} from '@fluxer/s3/src/s3/S3Service';
function resolveExpirationDbPath(): string {
const basePath = Config.database.sqlitePath ?? ':memory:';
if (basePath === ':memory:') {
return basePath;
}
const dir = path.dirname(basePath);
fs.mkdirSync(dir, {recursive: true});
return path.join(dir, DIRECT_S3_EXPIRATION_DB_FILENAME);
}
function buildExpirationKey(bucket: string, key: string): string {
return encodeKey([bucket, key]);
}
function buildBucketPrefix(bucket: string): string {
return `${encodeKey([bucket])}|`;
}
export class DirectS3ExpirationManager {
private readonly store: DirectS3ExpirationStore;
private readonly timers = new Map<string, NodeJS.Timeout>();
private readonly s3Service: S3Service;
private bootstrapPromise: Promise<void>;
constructor(s3Service: S3Service) {
this.s3Service = s3Service;
this.store = new DirectS3ExpirationStore(resolveExpirationDbPath());
this.bootstrapPromise = this.bootstrap().catch((error) => {
Logger.error({error}, 'Failed to bootstrap direct S3 expirations');
});
}
async trackExpiration(params: {bucket: string; key: string; expiresAt: Date}): Promise<void> {
await this.bootstrapPromise;
const expiresAtMs = this.normaliseExpiresAt(params.expiresAt);
const entry: DirectS3ExpirationEntry = {
bucket: params.bucket,
key: params.key,
expiresAtMs,
};
this.store.upsert(entry);
Logger.debug({bucket: params.bucket, key: params.key, expiresAt: params.expiresAt}, 'Tracked S3 expiration');
if (expiresAtMs <= Date.now()) {
await this.expireObject(entry);
return;
}
this.scheduleExpiration(entry);
}
clearExpiration(bucket: string, key: string): void {
this.store.delete(bucket, key);
this.clearTimer(bucket, key);
Logger.debug({bucket, key}, 'Cleared S3 expiration');
}
clearBucket(bucket: string): void {
this.store.deleteBucket(bucket);
const prefix = buildBucketPrefix(bucket);
for (const [timerKey, timer] of this.timers) {
if (timerKey.startsWith(prefix)) {
clearTimeout(timer);
this.timers.delete(timerKey);
}
}
Logger.debug({bucket}, 'Cleared S3 expiration bucket');
}
getExpiration(bucket: string, key: string): Date | null {
const expiresAtMs = this.store.getExpiresAtMs(bucket, key);
if (expiresAtMs === null) {
return null;
}
return new Date(expiresAtMs);
}
async expireIfNeeded(bucket: string, key: string): Promise<boolean> {
await this.bootstrapPromise;
const expiresAtMs = this.store.getExpiresAtMs(bucket, key);
if (expiresAtMs === null) {
return false;
}
if (expiresAtMs > Date.now()) {
return false;
}
Logger.debug({bucket, key, expiresAt: new Date(expiresAtMs)}, 'S3 object expired');
await this.expireObject({bucket, key, expiresAtMs});
return true;
}
private normaliseExpiresAt(expiresAt: Date): number {
const ms = expiresAt.getTime();
if (!Number.isFinite(ms)) {
throw new TypeError('expiresAt must be a valid Date');
}
return Number.isSafeInteger(ms) ? ms : Number.MAX_SAFE_INTEGER;
}
private clearTimer(bucket: string, key: string): void {
const timerKey = buildExpirationKey(bucket, key);
const timer = this.timers.get(timerKey);
if (!timer) {
return;
}
clearTimeout(timer);
this.timers.delete(timerKey);
}
private scheduleExpiration(entry: DirectS3ExpirationEntry): void {
const now = Date.now();
const delay = Math.min(entry.expiresAtMs - now, DIRECT_S3_EXPIRATION_TIMER_MAX_DELAY_MS);
if (delay <= 0) {
void this.handleTimer(entry);
return;
}
const timerKey = buildExpirationKey(entry.bucket, entry.key);
const existingTimer = this.timers.get(timerKey);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
void this.handleTimer(entry);
}, delay);
this.timers.set(timerKey, timer);
Logger.debug(
{bucket: entry.bucket, key: entry.key, expiresAt: new Date(entry.expiresAtMs), delayMs: delay},
'Scheduled S3 expiration',
);
}
private scheduleRetry(entry: DirectS3ExpirationEntry): void {
const timerKey = buildExpirationKey(entry.bucket, entry.key);
const existingTimer = this.timers.get(timerKey);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
void this.handleTimer(entry);
}, DIRECT_S3_EXPIRATION_RETRY_DELAY_MS);
this.timers.set(timerKey, timer);
Logger.debug(
{
bucket: entry.bucket,
key: entry.key,
expiresAt: new Date(entry.expiresAtMs),
delayMs: DIRECT_S3_EXPIRATION_RETRY_DELAY_MS,
},
'Rescheduled S3 expiration cleanup',
);
}
private async handleTimer(entry: DirectS3ExpirationEntry): Promise<void> {
const expiresAtMs = this.store.getExpiresAtMs(entry.bucket, entry.key);
if (expiresAtMs === null) {
this.clearTimer(entry.bucket, entry.key);
return;
}
if (expiresAtMs > Date.now()) {
this.scheduleExpiration({bucket: entry.bucket, key: entry.key, expiresAtMs});
return;
}
await this.expireObject({bucket: entry.bucket, key: entry.key, expiresAtMs});
}
private async expireObject(entry: DirectS3ExpirationEntry): Promise<void> {
const currentExpiresAtMs = this.store.getExpiresAtMs(entry.bucket, entry.key);
if (currentExpiresAtMs === null) {
this.clearTimer(entry.bucket, entry.key);
return;
}
if (currentExpiresAtMs !== entry.expiresAtMs) {
if (currentExpiresAtMs > Date.now()) {
this.scheduleExpiration({bucket: entry.bucket, key: entry.key, expiresAtMs: currentExpiresAtMs});
return;
}
entry = {bucket: entry.bucket, key: entry.key, expiresAtMs: currentExpiresAtMs};
}
try {
await this.s3Service.deleteObject(entry.bucket, entry.key);
this.store.delete(entry.bucket, entry.key);
this.clearTimer(entry.bucket, entry.key);
Logger.debug({bucket: entry.bucket, key: entry.key}, 'Expired S3 object deleted');
} catch (error) {
Logger.error({error, bucket: entry.bucket, key: entry.key}, 'Failed to delete expired S3 object');
this.scheduleRetry(entry);
}
}
private async bootstrap(): Promise<void> {
const entries = this.store.listAll();
for (const entry of entries) {
if (entry.expiresAtMs <= Date.now()) {
await this.expireObject(entry);
} else {
this.scheduleExpiration(entry);
}
}
}
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {DatabaseSync, type SQLOutputValue, type StatementSync} from 'node:sqlite';
import {DIRECT_S3_EXPIRATION_TABLE} from '@fluxer/constants/src/StorageConstants';
export interface DirectS3ExpirationEntry {
bucket: string;
key: string;
expiresAtMs: number;
}
export class DirectS3ExpirationStore {
private readonly db: DatabaseSync;
private readonly upsertStmt: StatementSync;
private readonly deleteStmt: StatementSync;
private readonly deleteBucketStmt: StatementSync;
private readonly getStmt: StatementSync;
private readonly listAllStmt: StatementSync;
constructor(dbPath: string) {
this.db = new DatabaseSync(dbPath);
this.db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS ${DIRECT_S3_EXPIRATION_TABLE} (
bucket TEXT NOT NULL,
key TEXT NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY(bucket, key)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS ${DIRECT_S3_EXPIRATION_TABLE}_expires_idx
ON ${DIRECT_S3_EXPIRATION_TABLE}(expires_at);
`);
this.upsertStmt = this.db.prepare(`
INSERT INTO ${DIRECT_S3_EXPIRATION_TABLE} (bucket, key, expires_at)
VALUES (?, ?, ?)
ON CONFLICT(bucket, key) DO UPDATE SET
expires_at = excluded.expires_at;
`);
this.deleteStmt = this.db.prepare(`DELETE FROM ${DIRECT_S3_EXPIRATION_TABLE} WHERE bucket = ? AND key = ?;`);
this.deleteBucketStmt = this.db.prepare(`DELETE FROM ${DIRECT_S3_EXPIRATION_TABLE} WHERE bucket = ?;`);
this.getStmt = this.db.prepare(
`SELECT expires_at FROM ${DIRECT_S3_EXPIRATION_TABLE} WHERE bucket = ? AND key = ?;`,
);
this.listAllStmt = this.db.prepare(
`SELECT bucket, key, expires_at FROM ${DIRECT_S3_EXPIRATION_TABLE} ORDER BY expires_at ASC;`,
);
}
upsert(entry: DirectS3ExpirationEntry): void {
this.upsertStmt.run(entry.bucket, entry.key, entry.expiresAtMs);
}
delete(bucket: string, key: string): void {
this.deleteStmt.run(bucket, key);
}
deleteBucket(bucket: string): void {
this.deleteBucketStmt.run(bucket);
}
getExpiresAtMs(bucket: string, key: string): number | null {
const row = this.getStmt.get(bucket, key) as Record<string, SQLOutputValue> | undefined;
if (!row) {
return null;
}
const expiresAt = row['expires_at'];
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
throw new TypeError('Invalid expires_at value in direct S3 expiration store');
}
return expiresAt;
}
listAll(): Array<DirectS3ExpirationEntry> {
const rows = this.listAllStmt.all();
return rows.map((row) => this.parseRow(row));
}
private parseRow(row: Record<string, SQLOutputValue>): DirectS3ExpirationEntry {
const bucket = row['bucket'];
const key = row['key'];
const expiresAt = row['expires_at'];
if (typeof bucket !== 'string' || typeof key !== 'string') {
throw new TypeError('Invalid bucket or key in direct S3 expiration store');
}
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
throw new TypeError('Invalid expires_at value in direct S3 expiration store');
}
return {bucket, key, expiresAtMs: expiresAt};
}
}

View File

@@ -0,0 +1,400 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs';
import path from 'node:path';
import {PassThrough, pipeline, type Readable} from 'node:stream';
import {promisify} from 'node:util';
import {Config} from '@fluxer/api/src/Config';
import {DirectS3ExpirationManager} from '@fluxer/api/src/infrastructure/DirectS3ExpirationManager';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import {processAndUploadJpeg, streamToBuffer} from '@fluxer/api/src/infrastructure/StorageObjectHelpers';
import {Logger} from '@fluxer/api/src/Logger';
import {S3Errors} from '@fluxer/s3/src/errors/S3Error';
import {generatePresignedUrl} from '@fluxer/s3/src/s3/PresignedUrlGenerator';
import type {S3Service} from '@fluxer/s3/src/s3/S3Service';
import {seconds} from 'itty-time';
const pipelinePromise = promisify(pipeline);
const expirationManagers = new WeakMap<S3Service, DirectS3ExpirationManager>();
function getExpirationManager(s3Service: S3Service): DirectS3ExpirationManager {
const existing = expirationManagers.get(s3Service);
if (existing) {
return existing;
}
const manager = new DirectS3ExpirationManager(s3Service);
expirationManagers.set(s3Service, manager);
return manager;
}
export class DirectS3StorageService implements IStorageService {
private readonly s3Service: S3Service;
private readonly presignedUrlBase: string;
private readonly expirationManager: DirectS3ExpirationManager;
constructor(s3Service: S3Service) {
this.s3Service = s3Service;
this.presignedUrlBase = Config.s3.presignedUrlBase ?? Config.s3.endpoint;
this.expirationManager = getExpirationManager(s3Service);
}
async uploadObject({
bucket,
key,
body,
contentType,
expiresAt,
}: {
bucket: string;
key: string;
body: Uint8Array;
contentType?: string;
expiresAt?: Date;
}): Promise<void> {
const buffer = Buffer.from(body);
if (expiresAt) {
Logger.debug({bucket, key, expiresAt}, 'Uploading S3 object with expiration');
}
await this.s3Service.putObject(bucket, key, buffer, {contentType});
if (expiresAt) {
try {
await this.expirationManager.trackExpiration({bucket, key, expiresAt});
} catch (error) {
await this.s3Service.deleteObject(bucket, key);
throw error;
}
} else {
this.expirationManager.clearExpiration(bucket, key);
}
}
async getPresignedDownloadURL({
bucket,
key,
expiresIn = seconds('5 minutes'),
}: {
bucket: string;
key: string;
expiresIn?: number;
}): Promise<string> {
const expired = await this.expirationManager.expireIfNeeded(bucket, key);
if (expired) {
throw S3Errors.noSuchKey(key);
}
const {accessKeyId, secretAccessKey} = Config.s3;
if (!accessKeyId || !secretAccessKey) {
throw new Error('S3 credentials not configured');
}
return generatePresignedUrl({
method: 'GET',
bucket,
key,
expiresIn: Math.floor(expiresIn),
accessKey: accessKeyId,
secretKey: secretAccessKey,
endpoint: this.presignedUrlBase,
region: Config.s3.region,
});
}
async deleteObject(bucket: string, key: string): Promise<void> {
await this.s3Service.deleteObject(bucket, key);
this.expirationManager.clearExpiration(bucket, key);
}
async getObjectMetadata(bucket: string, key: string): Promise<{contentLength: number; contentType: string} | null> {
const expired = await this.expirationManager.expireIfNeeded(bucket, key);
if (expired) {
return null;
}
try {
const metadata = await this.s3Service.headObject(bucket, key);
return {
contentLength: metadata.size,
contentType: metadata.contentType ?? 'application/octet-stream',
};
} catch (error) {
const errorCode =
error && typeof error === 'object' && 'code' in error ? (error as {code: string}).code : undefined;
if (errorCode === 'NoSuchKey' || errorCode === 'NotFound') {
return null;
}
throw error;
}
}
async readObject(bucket: string, key: string): Promise<Uint8Array> {
const expired = await this.expirationManager.expireIfNeeded(bucket, key);
if (expired) {
throw S3Errors.noSuchKey(key);
}
const {stream} = await this.s3Service.getObject(bucket, key);
const passThrough = new PassThrough();
stream.pipe(passThrough);
return streamToBuffer(passThrough);
}
async streamObject(params: {bucket: string; key: string; range?: string}): Promise<{
body: Readable;
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null> {
const expired = await this.expirationManager.expireIfNeeded(params.bucket, params.key);
if (expired) {
return null;
}
const rangeOption = params.range
? (() => {
const [start, end] = params.range!.split('=')[1].split('-').map(Number);
return {start, end};
})()
: undefined;
const result = await this.s3Service.getObject(
params.bucket,
params.key,
rangeOption ? {range: rangeOption} : undefined,
);
return {
body: result.stream as Readable,
contentLength: result.metadata.size,
contentRange: result.contentRange ?? null,
contentType: result.metadata.contentType ?? null,
cacheControl: null,
contentDisposition: null,
expires: null,
etag: result.metadata.etag,
lastModified: result.metadata.lastModified,
};
}
private async ensureDirectoryExists(dirPath: string): Promise<void> {
await fs.promises.mkdir(dirPath, {recursive: true});
}
async writeObjectToDisk(bucket: string, key: string, filePath: string): Promise<void> {
const expired = await this.expirationManager.expireIfNeeded(bucket, key);
if (expired) {
throw S3Errors.noSuchKey(key);
}
await this.ensureDirectoryExists(path.dirname(filePath));
const {stream} = await this.s3Service.getObject(bucket, key);
const writeStream = fs.createWriteStream(filePath);
try {
await pipelinePromise(stream, writeStream);
} catch (error) {
writeStream.destroy();
throw error;
}
}
async copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise<void> {
const expired = await this.expirationManager.expireIfNeeded(sourceBucket, sourceKey);
if (expired) {
throw S3Errors.noSuchKey(sourceKey);
}
await this.s3Service.copyObject(
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType
? {
metadataDirective: 'REPLACE',
contentType: newContentType,
}
: undefined,
);
const sourceExpiresAt = this.expirationManager.getExpiration(sourceBucket, sourceKey);
if (sourceExpiresAt) {
try {
await this.expirationManager.trackExpiration({
bucket: destinationBucket,
key: destinationKey,
expiresAt: sourceExpiresAt,
});
} catch (error) {
await this.s3Service.deleteObject(destinationBucket, destinationKey);
throw error;
}
} else {
this.expirationManager.clearExpiration(destinationBucket, destinationKey);
}
}
async copyObjectWithJpegProcessing({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
contentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
contentType: string;
}): Promise<{width: number; height: number} | null> {
const isJpeg = contentType.toLowerCase().includes('jpeg') || contentType.toLowerCase().includes('jpg');
const sourceExpiresAt = this.expirationManager.getExpiration(sourceBucket, sourceKey);
if (!isJpeg) {
await this.copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType: contentType,
});
return null;
}
try {
const sourceData = await this.readObject(sourceBucket, sourceKey);
const result = await processAndUploadJpeg({
sourceData,
contentType,
destination: {bucket: destinationBucket, key: destinationKey},
uploadObject: async (params) => this.uploadObject(params),
});
if (sourceExpiresAt) {
try {
await this.expirationManager.trackExpiration({
bucket: destinationBucket,
key: destinationKey,
expiresAt: sourceExpiresAt,
});
} catch (error) {
await this.s3Service.deleteObject(destinationBucket, destinationKey);
throw error;
}
} else {
this.expirationManager.clearExpiration(destinationBucket, destinationKey);
}
return result;
} catch (error) {
Logger.error({error}, 'Failed to process JPEG, falling back to simple copy');
await this.copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType: contentType,
});
return null;
}
}
async moveObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise<void> {
await this.copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
});
await this.deleteObject(sourceBucket, sourceKey);
}
async purgeBucket(bucket: string): Promise<void> {
const result = await this.s3Service.listObjects(bucket);
const keys = result.contents.map((obj) => obj.key);
if (keys.length > 0) {
const deleteResult = await this.s3Service.deleteObjects(bucket, keys);
for (const key of deleteResult.deleted) {
this.expirationManager.clearExpiration(bucket, key);
}
}
this.expirationManager.clearBucket(bucket);
Logger.debug({bucket}, 'Purged bucket');
}
async uploadAvatar(params: {prefix: string; key: string; body: Uint8Array}): Promise<void> {
const {prefix, key, body} = params;
await this.uploadObject({
bucket: Config.s3.buckets.cdn,
key: `${prefix}/${key}`,
body,
});
}
async deleteAvatar(params: {prefix: string; key: string}): Promise<void> {
const {prefix, key} = params;
await this.deleteObject(Config.s3.buckets.cdn, `${prefix}/${key}`);
}
async listObjects(params: {bucket: string; prefix: string}): Promise<
ReadonlyArray<{
key: string;
lastModified?: Date;
}>
> {
const result = await this.s3Service.listObjects(params.bucket, {prefix: params.prefix});
return result.contents.map((obj) => ({
key: obj.key,
lastModified: obj.lastModified,
}));
}
async deleteObjects(params: {bucket: string; objects: ReadonlyArray<{Key: string}>}): Promise<void> {
const keys = params.objects.map((obj) => obj.Key);
if (keys.length === 0) {
return;
}
const result = await this.s3Service.deleteObjects(params.bucket, keys);
for (const key of result.deleted) {
this.expirationManager.clearExpiration(params.bucket, key);
}
}
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {ILiveKitService, ListParticipantsResult} from '@fluxer/api/src/infrastructure/ILiveKitService';
import type {VoiceRegionMetadata, VoiceServerRecord} from '@fluxer/api/src/voice/VoiceModel';
interface CreateTokenParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
}
interface UpdateParticipantParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
}
interface DisconnectParticipantParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
}
interface UpdateParticipantPermissionsParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
canSpeak: boolean;
canStream: boolean;
canVideo: boolean;
}
export class DisabledLiveKitService implements ILiveKitService {
async createToken(_params: CreateTokenParams): Promise<{token: string; endpoint: string}> {
throw new Error('Voice is disabled');
}
async updateParticipant(_params: UpdateParticipantParams): Promise<void> {}
async updateParticipantPermissions(_params: UpdateParticipantPermissionsParams): Promise<void> {}
async disconnectParticipant(_params: DisconnectParticipantParams): Promise<void> {}
async listParticipants(_params: {
guildId?: GuildID;
channelId: ChannelID;
regionId: string;
serverId: string;
}): Promise<ListParticipantsResult> {
return {status: 'ok', participants: []};
}
getDefaultRegionId(): string | null {
return null;
}
getRegionMetadata(): Array<VoiceRegionMetadata> {
return [];
}
getServer(_regionId: string, _serverId: string): VoiceServerRecord | null {
return null;
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import type {IVirusScanService} from '@fluxer/virus_scan/src/IVirusScanService';
import type {VirusScanResult} from '@fluxer/virus_scan/src/VirusScanResult';
export class DisabledVirusScanService implements IVirusScanService {
private cachedVirusHashes = new Set<string>();
async initialize(): Promise<void> {}
async scanFile(filePath: string): Promise<VirusScanResult> {
const fileHash = crypto.createHash('sha256').update(filePath).digest('hex');
return {
isClean: true,
fileHash,
};
}
async scanBuffer(buffer: Buffer, _filename: string): Promise<VirusScanResult> {
const fileHash = crypto.createHash('sha256').update(buffer).digest('hex');
return {
isClean: true,
fileHash,
};
}
async isVirusHashCached(fileHash: string): Promise<boolean> {
return this.cachedVirusHashes.has(fileHash);
}
async cacheVirusHash(fileHash: string): Promise<void> {
this.cachedVirusHashes.add(fileHash);
}
}

View File

@@ -0,0 +1,292 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import {Logger} from '@fluxer/api/src/Logger';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {NON_SELF_HOSTED_RESERVED_DISCRIMINATORS} from '@fluxer/constants/src/DiscriminatorConstants';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import {ms, seconds} from 'itty-time';
interface GenerateDiscriminatorParams {
username: string;
requestedDiscriminator?: number;
user?: User | null;
}
interface GenerateDiscriminatorResult {
discriminator: number;
available: boolean;
}
interface ResolveUsernameChangeParams {
currentUsername: string;
currentDiscriminator: number;
newUsername: string;
user?: User | null;
requestedDiscriminator?: number;
}
interface ResolveUsernameChangeResult {
username: string;
discriminator: number;
}
export class UsernameNotAvailableError extends BadRequestError {
constructor() {
super({code: APIErrorCodes.USERNAME_NOT_AVAILABLE});
this.name = 'UsernameNotAvailableError';
}
}
export interface IDiscriminatorService {
generateDiscriminator(params: GenerateDiscriminatorParams): Promise<GenerateDiscriminatorResult>;
isDiscriminatorAvailableForUsername(username: string, discriminator: number): Promise<boolean>;
resolveUsernameChange(params: ResolveUsernameChangeParams): Promise<ResolveUsernameChangeResult>;
}
export class DiscriminatorService implements IDiscriminatorService {
private static readonly LOCK_TTL_MS = ms('5 seconds');
private static readonly LOCK_RETRY_DELAY_MS = 50;
private static readonly LOCK_MAX_WAIT_MS = ms('10 seconds');
private static readonly DISCRIM_CACHE_TTL_S = seconds('30 seconds');
constructor(
private userRepository: IUserRepository,
private cacheService: ICacheService,
private limitConfigService: LimitConfigService,
) {}
private async canUseCustomDiscriminator(user?: User | null): Promise<boolean> {
if (Config.instance.selfHosted) {
return true;
}
if (!user) {
return false;
}
const ctx = createLimitMatchContext({user});
const hasCustomDiscriminator = resolveLimitSafe(
this.limitConfigService.getConfigSnapshot(),
ctx,
'feature_custom_discriminator',
0,
);
return hasCustomDiscriminator > 0;
}
async generateDiscriminator(params: GenerateDiscriminatorParams): Promise<GenerateDiscriminatorResult> {
const {username, requestedDiscriminator, user} = params;
const usernameLower = username.toLowerCase();
const lockKey = `discrim-lock:${usernameLower}`;
const lockToken = await this.acquireLockWithRetry(lockKey);
if (!lockToken) {
return {discriminator: -1, available: false};
}
try {
const allowCustomDiscriminator = await this.canUseCustomDiscriminator(user);
if (allowCustomDiscriminator && requestedDiscriminator !== undefined) {
const isAvailable = await this.isDiscriminatorAvailable(usernameLower, requestedDiscriminator);
if (isAvailable) {
await this.cacheClaimedDiscriminator(usernameLower, requestedDiscriminator);
return {discriminator: requestedDiscriminator, available: true};
}
return {discriminator: requestedDiscriminator, available: false};
}
const discriminator = await this.generateRandomDiscriminator(usernameLower);
if (discriminator === -1) {
return {discriminator: -1, available: false};
}
await this.cacheClaimedDiscriminator(usernameLower, discriminator);
return {discriminator, available: true};
} finally {
await this.releaseLock(lockKey, lockToken);
}
}
async isDiscriminatorAvailableForUsername(username: string, discriminator: number): Promise<boolean> {
const usernameLower = username.toLowerCase();
const lockKey = `discrim-lock:${usernameLower}`;
const lockToken = await this.acquireLockWithRetry(lockKey);
if (!lockToken) {
return false;
}
try {
return await this.isDiscriminatorAvailable(usernameLower, discriminator);
} finally {
await this.releaseLock(lockKey, lockToken);
}
}
async resolveUsernameChange(params: ResolveUsernameChangeParams): Promise<ResolveUsernameChangeResult> {
const {currentUsername, currentDiscriminator, newUsername, user, requestedDiscriminator} = params;
if (
currentUsername.toLowerCase() === newUsername.toLowerCase() &&
(requestedDiscriminator === undefined || requestedDiscriminator === currentDiscriminator)
) {
return {username: newUsername, discriminator: currentDiscriminator};
}
const allowCustomDiscriminator = await this.canUseCustomDiscriminator(user);
const discriminatorToRequest = allowCustomDiscriminator ? requestedDiscriminator : undefined;
const result = await this.generateDiscriminator({
username: newUsername,
requestedDiscriminator: discriminatorToRequest,
user,
});
if (!result.available || result.discriminator === -1) {
throw new UsernameNotAvailableError();
}
return {username: newUsername, discriminator: result.discriminator};
}
private async acquireLockWithRetry(lockKey: string): Promise<string | null> {
const startTime = Date.now();
while (Date.now() - startTime < DiscriminatorService.LOCK_MAX_WAIT_MS) {
const token = await this.acquireLock(lockKey);
if (token) {
return token;
}
try {
await this.sleep(DiscriminatorService.LOCK_RETRY_DELAY_MS);
} catch (error) {
Logger.error({lockKey, error}, 'Error during lock retry sleep');
return null;
}
}
return null;
}
private async acquireLock(lockKey: string): Promise<string | null> {
try {
const ttlSeconds = Math.ceil(DiscriminatorService.LOCK_TTL_MS / 1000);
return await this.cacheService.acquireLock(lockKey, ttlSeconds);
} catch (error) {
Logger.error({lockKey, error}, 'Failed to acquire discriminator lock');
return null;
}
}
private async releaseLock(lockKey: string, token: string): Promise<void> {
try {
await this.cacheService.releaseLock(lockKey, token);
} catch (error) {
Logger.error({lockKey, error}, 'Failed to release discriminator lock');
}
}
private async isDiscriminatorAvailable(username: string, discriminator: number): Promise<boolean> {
const cacheKey = `discrim-claimed:${username}`;
const isCached = await this.cacheService.sismember(cacheKey, discriminator.toString());
if (isCached) {
return false;
}
const user = await this.userRepository.findByUsernameDiscriminator(username, discriminator);
return user === null;
}
private async generateRandomDiscriminator(username: string): Promise<number> {
const takenDiscriminators = await this.userRepository.findDiscriminatorsByUsername(username);
const cachedDiscriminators = await this.getCachedDiscriminators(username);
const allTaken = new Set([...takenDiscriminators, ...cachedDiscriminators]);
if (!Config.instance.selfHosted) {
for (const reservedDiscriminator of NON_SELF_HOSTED_RESERVED_DISCRIMINATORS) {
allTaken.add(reservedDiscriminator);
}
}
if (allTaken.size >= 9999) {
return -1;
}
for (let attempts = 0; attempts < 10; attempts++) {
const randomDiscrim = Math.floor(Math.random() * 9999) + 1;
if (!allTaken.has(randomDiscrim)) {
return randomDiscrim;
}
}
for (let i = 1; i <= 9999; i++) {
if (!allTaken.has(i)) {
return i;
}
}
return -1;
}
private async cacheClaimedDiscriminator(username: string, discriminator: number): Promise<void> {
const cacheKey = `discrim-claimed:${username}`;
await this.cacheService.sadd(cacheKey, discriminator.toString(), DiscriminatorService.DISCRIM_CACHE_TTL_S);
}
private async getCachedDiscriminators(username: string): Promise<Set<number>> {
const cacheKey = `discrim-claimed:${username}`;
const members = await this.cacheService.smembers(cacheKey);
const discriminators = new Set<number>();
for (const member of members) {
const discrim = parseInt(member, 10);
if (!Number.isNaN(discrim)) {
discriminators.add(discrim);
}
}
return discriminators;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
try {
resolve();
} catch (error) {
reject(error);
}
}, ms);
timeout.unref?.();
});
}
}

View File

@@ -0,0 +1,461 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MessageID} from '@fluxer/api/src/BrandedTypes';
import type {RichEmbedMediaWithMetadata} from '@fluxer/api/src/channel/EmbedTypes';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import {nextVersion} from '@fluxer/api/src/database/Cassandra';
import type {MessageEmbed, MessageEmbedChild} from '@fluxer/api/src/database/types/MessageTypes';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {IUnfurlerService} from '@fluxer/api/src/infrastructure/IUnfurlerService';
import {Embed} from '@fluxer/api/src/models/Embed';
import {EmbedAuthor} from '@fluxer/api/src/models/EmbedAuthor';
import {EmbedField} from '@fluxer/api/src/models/EmbedField';
import {EmbedFooter} from '@fluxer/api/src/models/EmbedFooter';
import {EmbedMedia} from '@fluxer/api/src/models/EmbedMedia';
import * as UnfurlerUtils from '@fluxer/api/src/utils/UnfurlerUtils';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {EmbedMediaFlags} from '@fluxer/constants/src/ChannelConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {MessageEmbedChildResponse, MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import type {
RichEmbedAuthorRequest,
RichEmbedFooterRequest,
RichEmbedMediaRequest,
RichEmbedRequest,
} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
import {seconds} from 'itty-time';
interface CreateEmbedsParams {
channelId: ChannelID;
messageId: MessageID;
content: string | null;
customEmbeds?: Array<RichEmbedRequest>;
guildId: bigint | null;
isNSFWAllowed: boolean;
}
export class EmbedService {
private readonly MAX_EMBED_CHARACTERS = 6000;
private readonly CACHE_DURATION_SECONDS = seconds('30 minutes');
constructor(
private channelRepository: IChannelRepository,
private cacheService: ICacheService,
private unfurlerService: IUnfurlerService,
private mediaService: IMediaService,
private workerService: IWorkerService,
) {}
async createAndSaveEmbeds(params: CreateEmbedsParams): Promise<Array<MessageEmbed> | null> {
if (params.customEmbeds?.length) {
return await this.processCustomEmbeds(params);
} else {
return await this.processUrlEmbeds(params);
}
}
async getInitialEmbeds(params: {
content: string | null;
customEmbeds?: Array<RichEmbedRequest>;
isNSFWAllowed?: boolean;
}): Promise<{embeds: Array<MessageEmbed> | null; hasUncachedUrls: boolean}> {
if (params.customEmbeds?.length) {
this.validateEmbedSize(params.customEmbeds);
const embeds = await Promise.all(
params.customEmbeds.map((embed) => this.createEmbed(embed, params.isNSFWAllowed ?? false)),
);
return {embeds: embeds.map((embed) => embed.toMessageEmbed()), hasUncachedUrls: false};
}
if (!params.content) {
return {embeds: null, hasUncachedUrls: false};
}
const urls = UnfurlerUtils.extractURLs(params.content);
if (!urls.length) {
return {embeds: null, hasUncachedUrls: false};
}
const {cachedEmbeds, uncachedUrls} = await this.getCachedEmbeds(urls);
return {
embeds: cachedEmbeds.length > 0 ? cachedEmbeds.map((embed) => embed.toMessageEmbed()) : null,
hasUncachedUrls: uncachedUrls.length > 0,
};
}
async enqueueUrlEmbedExtraction(
channelId: ChannelID,
messageId: MessageID,
guildId: bigint | null,
isNSFWAllowed: boolean,
): Promise<void> {
await this.enqueue(channelId, messageId, guildId, isNSFWAllowed);
}
async processUrl(url: string, isNSFWAllowed: boolean = false): Promise<Array<Embed>> {
const embedsData = await this.unfurlerService.unfurl(url, isNSFWAllowed);
return embedsData.map((embedData) => new Embed(this.mapResponseEmbed(embedData)));
}
async cacheEmbeds(url: string, embeds: Array<Embed>): Promise<void> {
if (!embeds.length) return;
const cacheKey = `url-embed:${url}`;
await this.cacheService.set(
cacheKey,
embeds.map((embed) => embed.toMessageEmbed()),
this.CACHE_DURATION_SECONDS,
);
}
private async processCustomEmbeds({
channelId,
messageId,
customEmbeds,
isNSFWAllowed,
}: CreateEmbedsParams): Promise<Array<MessageEmbed> | null> {
if (!customEmbeds?.length) return null;
this.validateEmbedSize(customEmbeds);
const embeds = await Promise.all(customEmbeds.map((embed) => this.createEmbed(embed, isNSFWAllowed)));
await this.updateMessageEmbeds(channelId, messageId, embeds);
return embeds.map((embed) => embed.toMessageEmbed());
}
private async processUrlEmbeds({
channelId,
messageId,
content,
guildId,
isNSFWAllowed,
}: CreateEmbedsParams): Promise<Array<MessageEmbed> | null> {
if (!content) {
await this.updateMessageEmbeds(channelId, messageId, []);
return null;
}
const urls = UnfurlerUtils.extractURLs(content);
if (!urls.length) {
await this.updateMessageEmbeds(channelId, messageId, []);
return null;
}
const {cachedEmbeds, uncachedUrls} = await this.getCachedEmbeds(urls);
if (cachedEmbeds.length) {
await this.updateMessageEmbeds(channelId, messageId, cachedEmbeds);
}
if (uncachedUrls.length) {
await this.enqueue(channelId, messageId, guildId, isNSFWAllowed);
}
return cachedEmbeds.length > 0 ? cachedEmbeds.map((embed) => embed.toMessageEmbed()) : null;
}
private mapResponseEmbed(embed: MessageEmbedResponse): MessageEmbed {
return {
...this.mapResponseEmbedChild(embed),
children:
embed.children && embed.children.length > 0
? embed.children.map((child) => this.mapResponseEmbedChild(child))
: null,
};
}
private mapResponseEmbedChild(embed: MessageEmbedChildResponse): MessageEmbedChild {
return {
type: embed.type ?? null,
title: embed.title ?? null,
description: embed.description ?? null,
url: embed.url ?? null,
timestamp: embed.timestamp ? new Date(embed.timestamp) : null,
color: embed.color ?? null,
author: embed.author
? {
name: embed.author.name ?? null,
url: embed.author.url ?? null,
icon_url: embed.author.icon_url ?? null,
}
: null,
provider: embed.provider
? {
name: embed.provider.name ?? null,
url: embed.provider.url ?? null,
}
: null,
thumbnail: this.mapResponseMedia(embed.thumbnail),
image: this.mapResponseMedia(embed.image),
video: this.mapResponseMedia(embed.video),
footer: embed.footer
? {
text: embed.footer.text ?? null,
icon_url: embed.footer.icon_url ?? null,
}
: null,
fields:
embed.fields && embed.fields.length > 0
? embed.fields.map((field) => ({
name: field.name ?? null,
value: field.value ?? null,
inline: field.inline ?? false,
}))
: null,
nsfw: embed.nsfw ?? null,
};
}
private mapResponseMedia(media?: MessageEmbedResponse['image']): MessageEmbed['image'] {
if (!media) return null;
return {
url: media.url,
content_type: media.content_type ?? null,
content_hash: media.content_hash ?? null,
width: media.width ?? null,
height: media.height ?? null,
description: media.description ?? null,
placeholder: media.placeholder ?? null,
duration: media.duration ?? null,
flags: media.flags,
};
}
private validateEmbedSize(embeds: Array<RichEmbedRequest>): void {
const totalChars = embeds.reduce<number>((sum, embed) => {
return (
sum +
(embed.title?.length || 0) +
(embed.description?.length || 0) +
(embed.footer?.text?.length || 0) +
(embed.author?.name?.length || 0) +
(embed.fields?.reduce((fieldSum, field) => fieldSum + field.name.length + field.value.length, 0) || 0)
);
}, 0);
if (totalChars > this.MAX_EMBED_CHARACTERS) {
throw InputValidationError.fromCode('embeds', ValidationErrorCodes.EMBEDS_EXCEED_MAX_CHARACTERS, {
maxCharacters: this.MAX_EMBED_CHARACTERS,
});
}
}
private async createEmbed(
embed: RichEmbedRequest & {
image?: RichEmbedMediaWithMetadata | null;
thumbnail?: RichEmbedMediaWithMetadata | null;
},
isNSFWAllowed: boolean,
): Promise<Embed> {
const [author, footer, imageResult, thumbnailResult] = await Promise.all([
this.processAuthor(embed.author ?? undefined, isNSFWAllowed),
this.processFooter(embed.footer ?? undefined, isNSFWAllowed),
this.processMedia(embed.image ?? undefined, isNSFWAllowed),
this.processMedia(embed.thumbnail ?? undefined, isNSFWAllowed),
]);
let nsfw: boolean | null = null;
const hasNSFWImage = imageResult?.nsfw ?? false;
const hasNSFWThumbnail = thumbnailResult?.nsfw ?? false;
if (hasNSFWImage || hasNSFWThumbnail) {
nsfw = true;
}
return new Embed({
type: 'rich',
title: embed.title ?? null,
description: embed.description ?? null,
url: embed.url ?? null,
timestamp: embed.timestamp ?? null,
color: embed.color ?? 0,
footer: footer?.toMessageEmbedFooter() ?? null,
image: imageResult?.media?.toMessageEmbedMedia() ?? null,
thumbnail: thumbnailResult?.media?.toMessageEmbedMedia() ?? null,
video: null,
provider: null,
author: author?.toMessageEmbedAuthor() ?? null,
fields:
embed.fields?.map((field) =>
new EmbedField({
name: field.name || null,
value: field.value || null,
inline: field.inline ?? false,
}).toMessageEmbedField(),
) ?? null,
children: null,
nsfw,
});
}
private async processMedia(
request?: RichEmbedMediaRequest | RichEmbedMediaWithMetadata,
isNSFWAllowed?: boolean,
): Promise<{media: EmbedMedia; nsfw: boolean} | null> {
if (!request?.url) return null;
if (request.url.startsWith('attachment://')) {
throw InputValidationError.fromCode('embeds', ValidationErrorCodes.UNRESOLVED_ATTACHMENT_URL);
}
const attachmentMetadata = (request as RichEmbedMediaWithMetadata)._attachmentMetadata;
if (attachmentMetadata) {
return {
media: new EmbedMedia({
url: request.url,
width: attachmentMetadata.width,
height: attachmentMetadata.height,
description: request.description ?? null,
content_type: attachmentMetadata.content_type,
content_hash: attachmentMetadata.content_hash,
placeholder: attachmentMetadata.placeholder,
flags: attachmentMetadata.flags,
duration: attachmentMetadata.duration,
}),
nsfw: attachmentMetadata.nsfw ?? false,
};
}
const metadata = await this.mediaService.getMetadata({
type: 'external',
url: request.url,
isNSFWAllowed: isNSFWAllowed ?? false,
});
if (!metadata) {
return {
media: new EmbedMedia({
url: request.url,
width: null,
height: null,
description: request.description ?? null,
content_type: null,
content_hash: null,
placeholder: null,
flags: 0,
duration: null,
}),
nsfw: false,
};
}
return {
media: new EmbedMedia({
url: request.url,
width: metadata.width ?? null,
height: metadata.height ?? null,
description: request.description ?? null,
content_type: metadata.content_type ?? null,
content_hash: metadata.content_hash ?? null,
placeholder: metadata.placeholder ?? null,
flags:
(metadata.animated ? EmbedMediaFlags.IS_ANIMATED : 0) |
(metadata.nsfw ? EmbedMediaFlags.CONTAINS_EXPLICIT_MEDIA : 0),
duration: metadata.duration ?? null,
}),
nsfw: metadata.nsfw,
};
}
private async processAuthor(author?: RichEmbedAuthorRequest, isNSFWAllowed?: boolean): Promise<EmbedAuthor | null> {
if (!author) return null;
let iconUrl: string | null = null;
if (author.icon_url) {
const metadata = await this.mediaService.getMetadata({
type: 'external',
url: author.icon_url,
isNSFWAllowed: isNSFWAllowed ?? false,
});
if (metadata) iconUrl = author.icon_url;
}
return new EmbedAuthor({
name: author.name,
url: author.url ?? null,
icon_url: iconUrl,
});
}
private async processFooter(footer?: RichEmbedFooterRequest, isNSFWAllowed?: boolean): Promise<EmbedFooter | null> {
if (!footer) return null;
let iconUrl: string | null = null;
if (footer.icon_url) {
const metadata = await this.mediaService.getMetadata({
type: 'external',
url: footer.icon_url,
isNSFWAllowed: isNSFWAllowed ?? false,
});
if (metadata) iconUrl = footer.icon_url;
}
return new EmbedFooter({
text: footer.text,
icon_url: iconUrl,
});
}
private async getCachedEmbeds(
urls: Array<string>,
): Promise<{cachedEmbeds: Array<Embed>; uncachedUrls: Array<string>}> {
const cachedEmbeds: Array<Embed> = [];
const uncachedUrls: Array<string> = [];
for (const url of urls) {
const cacheKey = `url-embed:${url}`;
const cached = await this.cacheService.get<Array<MessageEmbed>>(cacheKey);
if (cached && cached.length > 0) {
for (const embed of cached) {
cachedEmbeds.push(new Embed(embed));
}
} else {
uncachedUrls.push(url);
}
}
return {cachedEmbeds, uncachedUrls};
}
private async enqueue(
channelId: ChannelID,
messageId: MessageID,
guildId: bigint | null,
isNSFWAllowed: boolean,
): Promise<void> {
await this.workerService.addJob('extractEmbeds', {
guildId: guildId ? guildId.toString() : null,
channelId: channelId.toString(),
messageId: messageId.toString(),
isNSFWAllowed,
});
}
private async updateMessageEmbeds(channelId: ChannelID, messageId: MessageID, embeds: Array<Embed>): Promise<void> {
const currentMessage = await this.channelRepository.getMessage(channelId, messageId);
if (!currentMessage) return;
const currentRow = currentMessage.toRow();
const updatedData = {
...currentRow,
embeds: embeds.length > 0 ? embeds.map((embed) => embed.toMessageEmbed()) : null,
version: nextVersion(currentRow.version),
};
await this.channelRepository.upsertMessage(updatedData, currentRow);
}
}

View File

@@ -0,0 +1,507 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import {Config} from '@fluxer/api/src/Config';
import type {CsamResourceType} from '@fluxer/api/src/csam/CsamTypes';
import type {ICsamReportSnapshotService} from '@fluxer/api/src/csam/ICsamReportSnapshotService';
import type {ISynchronousCsamScanner} from '@fluxer/api/src/csam/ISynchronousCsamScanner';
import type {IAssetDeletionQueue} from '@fluxer/api/src/infrastructure/IAssetDeletionQueue';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import {Logger} from '@fluxer/api/src/Logger';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import {AVATAR_EXTENSIONS, AVATAR_MAX_SIZE} from '@fluxer/constants/src/LimitConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
import {ms} from 'itty-time';
export type AssetType = 'avatar' | 'banner' | 'icon' | 'splash' | 'embed_splash';
export type EntityType = 'user' | 'guild' | 'guild_member';
const ASSET_TYPE_TO_PREFIX: Record<AssetType, string> = {
avatar: 'avatars',
banner: 'banners',
icon: 'icons',
splash: 'splashes',
embed_splash: 'embed-splashes',
};
export interface PreparedAssetUpload {
newHash: string | null;
previousHash: string | null;
isAnimated: boolean;
newS3Key: string | null;
previousS3Key: string | null;
newCdnUrl: string | null;
previousCdnUrl: string | null;
height?: number;
width?: number;
imageBuffer?: Uint8Array;
format?: string | null;
contentType?: string | null;
_uploaded: boolean;
}
export interface PrepareAssetUploadOptions {
assetType: AssetType;
entityType: EntityType;
entityId: bigint;
guildId?: bigint;
previousHash: string | null;
base64Image: string | null;
errorPath: string;
}
export interface CommitAssetChangeOptions {
prepared: PreparedAssetUpload;
deferDeletion?: boolean;
}
type LimitConfigSnapshotProvider = Pick<LimitConfigService, 'getConfigSnapshot'>;
export class EntityAssetService {
private activeTimeouts: Set<NodeJS.Timeout> = new Set();
private readonly csamScanner?: ISynchronousCsamScanner;
private readonly csamReportService?: ICsamReportSnapshotService;
constructor(
private readonly storageService: IStorageService,
private readonly mediaService: IMediaService,
private readonly assetDeletionQueue: IAssetDeletionQueue,
private readonly limitConfigService: LimitConfigSnapshotProvider,
csamScanner?: ISynchronousCsamScanner,
csamReportService?: ICsamReportSnapshotService,
) {
this.csamScanner = csamScanner;
this.csamReportService = csamReportService;
}
async prepareAssetUpload(options: PrepareAssetUploadOptions): Promise<PreparedAssetUpload> {
const {assetType, entityType, entityId, guildId, previousHash, base64Image, errorPath} = options;
const s3KeyBase = this.buildS3KeyBase(assetType, entityType, entityId, guildId);
const cdnUrlBase = this.buildCdnUrlBase(assetType, entityType, entityId, guildId);
const previousS3Key = previousHash ? `${s3KeyBase}/${this.stripAnimationPrefix(previousHash)}` : null;
const previousCdnUrl = previousHash ? `${cdnUrlBase}/${previousHash}` : null;
if (!base64Image) {
return {
newHash: null,
previousHash,
isAnimated: false,
newS3Key: null,
previousS3Key,
newCdnUrl: null,
previousCdnUrl,
_uploaded: false,
format: null,
contentType: null,
};
}
const {imageBuffer, format, height, width, contentType, animated} = await this.validateAndProcessImage(
base64Image,
errorPath,
);
const imageHash = crypto.createHash('md5').update(Buffer.from(imageBuffer)).digest('hex');
const imageHashShort = imageHash.slice(0, 8);
const newHash = animated ? `a_${imageHashShort}` : imageHashShort;
const isAnimated = animated;
const newS3Key = `${s3KeyBase}/${imageHashShort}`;
const newCdnUrl = `${cdnUrlBase}/${newHash}`;
if (newHash === previousHash) {
return {
newHash,
previousHash,
isAnimated,
newS3Key,
previousS3Key,
newCdnUrl,
previousCdnUrl,
height,
width,
_uploaded: false,
imageBuffer,
format,
contentType,
};
}
if (this.csamScanner && this.csamReportService && contentType) {
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
const scanResult = await this.csamScanner.scanBase64({base64: base64Data, mimeType: contentType});
if (scanResult.isMatch && scanResult.matchResult) {
const resourceType = this.mapAssetTypeToResourceType(assetType);
const userId = this.resolveUserId({entityType, entityId, guildId});
const resolvedGuildId = this.resolveGuildId({entityType, entityId, guildId});
await this.csamReportService.createSnapshot({
scanResult: scanResult.matchResult,
resourceType,
userId: userId?.toString() ?? null,
guildId: resolvedGuildId?.toString() ?? null,
channelId: null,
messageId: null,
mediaData: Buffer.from(imageBuffer),
filename: `${assetType}-${newHash}`,
contentType,
});
Logger.warn(
{assetType, entityType, entityId: entityId.toString(), trackingId: scanResult.matchResult.trackingId},
'CSAM detected in entity asset upload - content blocked',
);
throw new ContentBlockedError();
}
}
await this.uploadToS3(assetType, entityType, newS3Key, imageBuffer);
const exists = await this.verifyAssetExistsWithRetry(assetType, entityType, newS3Key);
if (!exists) {
Logger.error(
{newS3Key, assetType, entityType},
'Asset upload verification failed - object does not exist after upload with retries',
);
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.FAILED_TO_UPLOAD_IMAGE);
}
const prepared: PreparedAssetUpload = {
newHash,
previousHash,
isAnimated,
newS3Key,
previousS3Key,
newCdnUrl,
previousCdnUrl,
height,
width,
_uploaded: true,
imageBuffer,
format,
contentType,
};
return prepared;
}
async commitAssetChange(options: CommitAssetChangeOptions): Promise<void> {
const {prepared, deferDeletion = true} = options;
if (!prepared.previousHash || !prepared.previousS3Key) {
return;
}
if (prepared.newHash === prepared.previousHash) {
return;
}
if (deferDeletion) {
await this.assetDeletionQueue.queueDeletion({
s3Key: prepared.previousS3Key,
cdnUrl: prepared.previousCdnUrl,
reason: 'asset_replaced',
});
Logger.debug(
{previousS3Key: prepared.previousS3Key, previousCdnUrl: prepared.previousCdnUrl},
'Queued old asset for deferred deletion',
);
} else {
await this.deleteAssetImmediately(prepared.previousS3Key, prepared.previousCdnUrl);
}
}
async rollbackAssetUpload(prepared: PreparedAssetUpload): Promise<void> {
if (!prepared._uploaded || !prepared.newS3Key) {
return;
}
try {
await this.storageService.deleteObject(Config.s3.buckets.cdn, prepared.newS3Key);
Logger.info({newS3Key: prepared.newS3Key}, 'Rolled back asset upload after DB failure');
} catch (error) {
Logger.error({error, newS3Key: prepared.newS3Key}, 'Failed to rollback asset upload - asset may be orphaned');
}
}
private mapAssetTypeToResourceType(assetType: AssetType): CsamResourceType {
switch (assetType) {
case 'avatar':
case 'icon':
return 'avatar';
case 'banner':
case 'splash':
case 'embed_splash':
return 'banner';
default:
return 'other';
}
}
private resolveUserId(params: {entityType: EntityType; entityId: bigint; guildId?: bigint}): bigint | null {
if (params.entityType === 'user') {
return params.entityId;
}
if (params.entityType === 'guild_member') {
return params.entityId;
}
return null;
}
private resolveGuildId(params: {entityType: EntityType; entityId: bigint; guildId?: bigint}): bigint | null {
if (params.entityType === 'guild') {
return params.entityId;
}
if (params.entityType === 'guild_member' && params.guildId) {
return params.guildId;
}
return null;
}
async verifyAssetExists(assetType: AssetType, entityType: EntityType, s3Key: string): Promise<boolean> {
try {
const metadata = await this.storageService.getObjectMetadata(Config.s3.buckets.cdn, s3Key);
return metadata !== null;
} catch (error) {
Logger.error({error, s3Key, assetType, entityType}, 'Error checking asset existence');
return false;
}
}
private resolveAvatarSizeLimit(): number {
const ctx = createLimitMatchContext({user: null});
const resolved = resolveLimit(this.limitConfigService.getConfigSnapshot(), ctx, 'avatar_max_size');
if (!Number.isFinite(resolved) || resolved < 0) {
return AVATAR_MAX_SIZE;
}
return Math.floor(resolved);
}
async verifyAssetExistsWithRetry(
assetType: AssetType,
entityType: EntityType,
s3Key: string,
maxRetries: number = 3,
delayMs: number = ms('500 milliseconds'),
): Promise<boolean> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const metadata = await this.storageService.getObjectMetadata(Config.s3.buckets.cdn, s3Key);
if (metadata !== null) {
if (attempt > 1) {
Logger.info({s3Key, assetType, entityType, attempt}, 'Asset verification succeeded after retry');
}
return true;
}
} catch (error) {
Logger.warn({error, s3Key, assetType, entityType, attempt}, 'Asset verification attempt failed');
}
if (attempt < maxRetries) {
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
this.activeTimeouts.delete(timeout);
resolve();
}, delayMs * attempt);
this.activeTimeouts.add(timeout);
timeout.unref?.();
});
}
}
Logger.error({s3Key, assetType, entityType, maxRetries}, 'Asset verification failed after all retries');
return false;
}
public cleanup(): void {
for (const timeout of this.activeTimeouts) {
clearTimeout(timeout);
}
this.activeTimeouts.clear();
}
public getActiveTimeoutCount(): number {
return this.activeTimeouts.size;
}
getS3KeyForHash(
assetType: AssetType,
entityType: EntityType,
entityId: bigint,
hash: string,
guildId?: bigint,
): string {
const s3KeyBase = this.buildS3KeyBase(assetType, entityType, entityId, guildId);
return `${s3KeyBase}/${this.stripAnimationPrefix(hash)}`;
}
getCdnUrlForHash(
assetType: AssetType,
entityType: EntityType,
entityId: bigint,
hash: string,
guildId?: bigint,
): string {
const cdnUrlBase = this.buildCdnUrlBase(assetType, entityType, entityId, guildId);
return `${cdnUrlBase}/${hash}`;
}
async queueAssetDeletion(
assetType: AssetType,
entityType: EntityType,
entityId: bigint,
hash: string,
guildId?: bigint,
reason: string = 'manual_clear',
): Promise<void> {
const s3Key = this.getS3KeyForHash(assetType, entityType, entityId, hash, guildId);
const cdnUrl = this.getCdnUrlForHash(assetType, entityType, entityId, hash, guildId);
await this.assetDeletionQueue.queueDeletion({
s3Key,
cdnUrl,
reason,
});
}
private stripAnimationPrefix(hash: string): string {
return hash.startsWith('a_') ? hash.substring(2) : hash;
}
private buildS3KeyBase(assetType: AssetType, entityType: EntityType, entityId: bigint, guildId?: bigint): string {
const prefix = ASSET_TYPE_TO_PREFIX[assetType];
if (entityType === 'guild_member') {
if (!guildId) {
throw new Error('guildId is required for guild_member assets');
}
return `guilds/${guildId}/users/${entityId}/${prefix}`;
}
return `${prefix}/${entityId}`;
}
private buildCdnUrlBase(assetType: AssetType, entityType: EntityType, entityId: bigint, guildId?: bigint): string {
const prefix = ASSET_TYPE_TO_PREFIX[assetType];
if (entityType === 'guild_member') {
if (!guildId) {
throw new Error('guildId is required for guild_member assets');
}
return `${Config.endpoints.media}/guilds/${guildId}/users/${entityId}/${prefix}`;
}
return `${Config.endpoints.media}/${prefix}/${entityId}`;
}
private async validateAndProcessImage(
base64Image: string,
errorPath: string,
): Promise<{
imageBuffer: Uint8Array;
format: string;
height?: number;
width?: number;
contentType: string | null;
animated: boolean;
}> {
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
let imageBuffer: Uint8Array;
try {
imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
} catch {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.INVALID_IMAGE_DATA);
}
const maxAvatarSize = this.resolveAvatarSizeLimit();
if (imageBuffer.length > maxAvatarSize) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, {
maxSize: maxAvatarSize,
});
}
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: base64Data,
isNSFWAllowed: false,
});
if (metadata == null || !AVATAR_EXTENSIONS.has(metadata.format)) {
throw InputValidationError.fromCode(errorPath, ValidationErrorCodes.INVALID_IMAGE_FORMAT, {
supportedExtensions: [...AVATAR_EXTENSIONS].join(', '),
});
}
const isAnimatedImage = metadata.animated ?? false;
return {
imageBuffer,
format: metadata.format,
height: metadata.height,
width: metadata.width,
contentType: metadata.content_type ?? null,
animated: isAnimatedImage,
};
}
private async uploadToS3(
assetType: AssetType,
entityType: EntityType,
s3Key: string,
imageBuffer: Uint8Array,
): Promise<void> {
try {
Logger.info({s3Key, assetType, entityType, size: imageBuffer.length}, 'Starting asset upload to S3');
await this.storageService.uploadObject({
bucket: Config.s3.buckets.cdn,
key: s3Key,
body: imageBuffer,
});
Logger.info({s3Key, assetType, entityType}, 'Asset upload to S3 completed successfully');
} catch (error) {
Logger.error({error, s3Key, assetType, entityType}, 'Asset upload to S3 failed');
throw new Error(`Failed to upload asset to S3: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async deleteAssetImmediately(s3Key: string, cdnUrl: string | null): Promise<void> {
try {
await this.storageService.deleteObject(Config.s3.buckets.cdn, s3Key);
Logger.debug({s3Key}, 'Deleted asset from S3');
} catch (error) {
Logger.error({error, s3Key}, 'Failed to delete asset from S3');
}
if (cdnUrl) {
await this.assetDeletionQueue.queueCdnPurge(cdnUrl);
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ErrorCodeToI18nKey} from '@fluxer/errors/src/i18n/ErrorCodeMappings';
import {getErrorMessage} from '@fluxer/errors/src/i18n/ErrorI18n';
export class ErrorI18nService {
getMessage(
code: string,
locale: string | null | undefined,
variables?: Record<string, unknown>,
fallbackMessage?: string,
): string {
const i18nKey = ErrorCodeToI18nKey[code as keyof typeof ErrorCodeToI18nKey] ?? code;
return getErrorMessage(i18nKey, locale, variables, fallbackMessage);
}
}

View File

@@ -0,0 +1,277 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import {GatewayRpcMethodError, GatewayRpcMethodErrorCodes} from '@fluxer/api/src/infrastructure/GatewayRpcError';
import {GatewayTcpRpcTransport, GatewayTcpTransportError} from '@fluxer/api/src/infrastructure/GatewayTcpRpcTransport';
import type {IGatewayRpcTransport} from '@fluxer/api/src/infrastructure/IGatewayRpcTransport';
import type {CallData} from '@fluxer/api/src/infrastructure/IGatewayService';
import {Logger} from '@fluxer/api/src/Logger';
import {recordCounter, recordHistogram} from '@fluxer/telemetry/src/Metrics';
import {ms} from 'itty-time';
interface GatewayRpcResponse {
result?: unknown;
error?: unknown;
}
const MAX_RETRY_ATTEMPTS = 3;
const TCP_FALLBACK_COOLDOWN_MS = ms('5 seconds');
const TCP_CONNECT_TIMEOUT_MS = 150;
const TCP_REQUEST_TIMEOUT_MS = ms('10 seconds');
const TCP_DEFAULT_PING_INTERVAL_MS = ms('15 seconds');
const TCP_MAX_PENDING_REQUESTS = 1024;
const TCP_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
interface GatewayRpcClientOptions {
tcpTransport?: IGatewayRpcTransport;
}
export class GatewayRpcClient {
private static instance: GatewayRpcClient | null = null;
private readonly httpEndpoint: string;
private readonly tcpTransport: IGatewayRpcTransport;
private tcpFallbackUntilMs = 0;
private constructor(options?: GatewayRpcClientOptions) {
this.httpEndpoint = `${Config.gateway.rpcEndpoint}/_rpc`;
this.tcpTransport = options?.tcpTransport ?? this.createGatewayTcpTransport();
}
static getInstance(): GatewayRpcClient {
if (!GatewayRpcClient.instance) {
GatewayRpcClient.instance = new GatewayRpcClient();
}
return GatewayRpcClient.instance;
}
static async resetForTests(): Promise<void> {
if (!GatewayRpcClient.instance) {
return;
}
await GatewayRpcClient.instance.tcpTransport.destroy();
GatewayRpcClient.instance = null;
}
private createGatewayTcpTransport(): GatewayTcpRpcTransport {
const endpointUrl = new URL(Config.gateway.rpcEndpoint);
return new GatewayTcpRpcTransport({
host: endpointUrl.hostname,
port: Config.gateway.rpcTcpPort,
authorization: `Bearer ${Config.gateway.rpcSecret}`,
connectTimeoutMs: TCP_CONNECT_TIMEOUT_MS,
requestTimeoutMs: TCP_REQUEST_TIMEOUT_MS,
defaultPingIntervalMs: TCP_DEFAULT_PING_INTERVAL_MS,
maxPendingRequests: TCP_MAX_PENDING_REQUESTS,
maxBufferBytes: TCP_MAX_BUFFER_BYTES,
logger: Logger,
});
}
async call<T>(method: string, params: Record<string, unknown>): Promise<T> {
Logger.debug(`[gateway-rpc] calling ${method}`);
const startTime = Date.now();
for (let attempt = 0; attempt <= MAX_RETRY_ATTEMPTS; attempt += 1) {
try {
const result = await this.executeCall(method, params);
const duration = Date.now() - startTime;
recordHistogram({
name: 'gateway.rpc.duration',
valueMs: duration,
dimensions: {method, success: 'true'},
});
return result as T;
} catch (error) {
const shouldRetry = this.shouldRetry(error, method);
if (attempt === MAX_RETRY_ATTEMPTS || !shouldRetry) {
const duration = Date.now() - startTime;
recordHistogram({
name: 'gateway.rpc.duration',
valueMs: duration,
dimensions: {method, success: 'false'},
});
recordCounter({
name: 'gateway.rpc.error',
dimensions: {method, attempt: attempt.toString()},
});
throw error;
}
const backoffMs = this.calculateBackoff(attempt);
Logger.warn({error, attempt: attempt + 1, backoffMs}, '[gateway-rpc] retrying failed request');
await this.delay(backoffMs);
}
}
throw new Error('Unexpected gateway RPC retry failure');
}
private async executeCall<T>(method: string, params: Record<string, unknown>): Promise<T> {
if (Date.now() >= this.tcpFallbackUntilMs) {
try {
const result = await this.tcpTransport.call(method, params);
return result as T;
} catch (error) {
if (!(error instanceof GatewayTcpTransportError)) {
throw error;
}
this.tcpFallbackUntilMs = Date.now() + TCP_FALLBACK_COOLDOWN_MS;
Logger.warn({error}, '[gateway-rpc] TCP transport unavailable, falling back to HTTP');
}
}
return this.executeHttpCall(method, params);
}
private async executeHttpCall<T>(method: string, params: Record<string, unknown>): Promise<T> {
let response: Response;
try {
response = await fetch(this.httpEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${Config.gateway.rpcSecret}`,
},
body: JSON.stringify({
method,
params,
}),
signal: AbortSignal.timeout(ms('10 seconds')),
});
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
Logger.error({method}, '[gateway-rpc] request timed out after 10s');
} else {
Logger.error({error}, '[gateway-rpc] request failed to reach gateway');
}
throw error;
}
const text = await response.text();
let payload: GatewayRpcResponse = {};
if (text.length > 0) {
try {
payload = JSON.parse(text) as GatewayRpcResponse;
} catch (error) {
Logger.error({error, body: text, status: response.status}, '[gateway-rpc] failed to parse response body');
throw new Error('Malformed gateway RPC response');
}
}
if (!response.ok) {
if (typeof payload.error === 'string' && payload.error.length > 0) {
throw new GatewayRpcMethodError(payload.error);
}
throw new Error(`Gateway RPC request failed with status ${response.status}`);
}
if (!Object.hasOwn(payload, 'result')) {
Logger.error({status: response.status, body: payload}, '[gateway-rpc] response missing result value');
throw new Error('Malformed gateway RPC response');
}
return payload.result as T;
}
private calculateBackoff(attempt: number): number {
const multiplier = 2 ** attempt;
return Math.min(500 * multiplier, ms('5 seconds'));
}
private shouldRetry(error: unknown, method: string): boolean {
if (error instanceof GatewayTcpTransportError) {
return true;
}
if (!(error instanceof Error)) {
return false;
}
if (error.name === 'TimeoutError') {
return true;
}
if (this.isRetryableOverloadError(error, method)) {
return true;
}
return error.name === 'TypeError';
}
private isRetryableOverloadError(error: Error, method: string): boolean {
if (!this.isDispatchMethod(method)) {
return false;
}
if (!(error instanceof GatewayRpcMethodError)) {
return false;
}
return error.code === GatewayRpcMethodErrorCodes.OVERLOADED;
}
private isDispatchMethod(method: string): boolean {
return method.endsWith('.dispatch');
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async getCall(channelId: string): Promise<CallData | null> {
return this.call<CallData | null>('call.get', {channel_id: channelId});
}
async createCall(
channelId: string,
messageId: string,
region: string,
ringing: Array<string>,
recipients: Array<string>,
): Promise<CallData> {
return this.call<CallData>('call.create', {
channel_id: channelId,
message_id: messageId,
region,
ringing,
recipients,
});
}
async updateCallRegion(channelId: string, region: string | null): Promise<boolean> {
return this.call('call.update_region', {channel_id: channelId, region});
}
async ringCallRecipients(channelId: string, recipients: Array<string>): Promise<boolean> {
return this.call('call.ring', {channel_id: channelId, recipients});
}
async stopRingingCallRecipients(channelId: string, recipients: Array<string>): Promise<boolean> {
return this.call('call.stop_ringing', {channel_id: channelId, recipients});
}
async deleteCall(channelId: string): Promise<boolean> {
return this.call('call.delete', {channel_id: channelId});
}
async getNodeStats(): Promise<unknown> {
return this.call('process.node_stats', {});
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export const GatewayRpcMethodErrorCodes = {
OVERLOADED: 'overloaded',
INTERNAL_ERROR: 'internal_error',
TIMEOUT: 'timeout',
GUILD_NOT_FOUND: 'guild_not_found',
FORBIDDEN: 'forbidden',
CHANNEL_NOT_FOUND: 'channel_not_found',
CHANNEL_NOT_VOICE: 'channel_not_voice',
CALL_ALREADY_EXISTS: 'call_already_exists',
CALL_NOT_FOUND: 'call_not_found',
USER_NOT_IN_VOICE: 'user_not_in_voice',
CONNECTION_NOT_FOUND: 'connection_not_found',
MODERATOR_MISSING_CONNECT: 'moderator_missing_connect',
TARGET_MISSING_CONNECT: 'target_missing_connect',
} as const;
export class GatewayRpcMethodError extends Error {
readonly code: string;
constructor(code: string) {
super(code);
this.name = 'GatewayRpcMethodError';
this.code = code;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export const MAX_GATEWAY_TCP_FRAME_BYTES = 1024 * 1024;
export interface DecodedGatewayTcpFrames {
frames: Array<unknown>;
remainder: Buffer;
}
export function encodeGatewayTcpFrame(frame: unknown, maxFrameBytes = MAX_GATEWAY_TCP_FRAME_BYTES): Buffer {
const payloadText = JSON.stringify(frame);
const payload = Buffer.from(payloadText, 'utf8');
if (payload.length > maxFrameBytes) {
throw new Error('Gateway TCP frame exceeds maximum size');
}
const header = Buffer.from(`${payload.length}\n`, 'utf8');
return Buffer.concat([header, payload]);
}
export function decodeGatewayTcpFrames(
buffer: Buffer,
maxFrameBytes = MAX_GATEWAY_TCP_FRAME_BYTES,
): DecodedGatewayTcpFrames {
let offset = 0;
const frames: Array<unknown> = [];
while (offset < buffer.length) {
const newlineIndex = buffer.indexOf(0x0a, offset);
if (newlineIndex < 0) {
break;
}
const lengthText = buffer.subarray(offset, newlineIndex).toString('utf8');
const frameLength = Number.parseInt(lengthText, 10);
if (!Number.isFinite(frameLength) || frameLength < 0 || frameLength > maxFrameBytes) {
throw new Error('Invalid Gateway TCP frame length');
}
const payloadStart = newlineIndex + 1;
const payloadEnd = payloadStart + frameLength;
if (payloadEnd > buffer.length) {
break;
}
const payload = buffer.subarray(payloadStart, payloadEnd).toString('utf8');
let frame: unknown;
try {
frame = JSON.parse(payload);
} catch {
throw new Error('Invalid Gateway TCP frame JSON');
}
frames.push(frame);
offset = payloadEnd;
}
return {
frames,
remainder: buffer.subarray(offset),
};
}

View File

@@ -0,0 +1,528 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createConnection, type Socket} from 'node:net';
import type {ILogger} from '@fluxer/api/src/ILogger';
import {GatewayRpcMethodError} from '@fluxer/api/src/infrastructure/GatewayRpcError';
import {
decodeGatewayTcpFrames,
encodeGatewayTcpFrame,
MAX_GATEWAY_TCP_FRAME_BYTES,
} from '@fluxer/api/src/infrastructure/GatewayTcpFrameCodec';
interface GatewayTcpRequestFrame {
type: 'request';
id: string;
method: string;
params: Record<string, unknown>;
}
interface GatewayTcpHelloFrame {
type: 'hello';
protocol: 'fluxer.rpc.tcp.v1';
authorization: string;
}
interface GatewayTcpHelloAckFrame {
type: 'hello_ack';
protocol: string;
max_in_flight?: number;
ping_interval_ms?: number;
}
interface GatewayTcpResponseFrame {
type: 'response';
id: string;
ok: boolean;
result?: unknown;
error?: unknown;
}
interface GatewayTcpErrorFrame {
type: 'error';
error?: unknown;
}
interface GatewayTcpPingFrame {
type: 'ping' | 'pong';
}
type TcpBuffer = Buffer<ArrayBufferLike>;
interface GatewayTcpRpcTransportOptions {
host: string;
port: number;
authorization: string;
connectTimeoutMs: number;
requestTimeoutMs: number;
defaultPingIntervalMs: number;
maxFrameBytes?: number;
maxPendingRequests?: number;
maxBufferBytes?: number;
logger: ILogger;
}
interface PendingGatewayTcpRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
const GATEWAY_TCP_PROTOCOL = 'fluxer.rpc.tcp.v1';
function isHelloAckFrame(frame: unknown): frame is GatewayTcpHelloAckFrame {
if (!frame || typeof frame !== 'object') {
return false;
}
const candidate = frame as {type?: unknown; protocol?: unknown};
return candidate.type === 'hello_ack' && typeof candidate.protocol === 'string';
}
function isResponseFrame(frame: unknown): frame is GatewayTcpResponseFrame {
if (!frame || typeof frame !== 'object') {
return false;
}
const candidate = frame as {type?: unknown; id?: unknown; ok?: unknown};
return candidate.type === 'response' && typeof candidate.id === 'string' && typeof candidate.ok === 'boolean';
}
function isErrorFrame(frame: unknown): frame is GatewayTcpErrorFrame {
if (!frame || typeof frame !== 'object') {
return false;
}
const candidate = frame as {type?: unknown};
return candidate.type === 'error';
}
export class GatewayTcpTransportError extends Error {
constructor(message: string) {
super(message);
this.name = 'GatewayTcpTransportError';
}
}
export class GatewayTcpRpcTransport {
private readonly options: GatewayTcpRpcTransportOptions;
private readonly maxFrameBytes: number;
private readonly maxPendingRequests: number;
private readonly maxBufferBytes: number;
private socket: Socket | null = null;
private connectPromise: Promise<void> | null = null;
private buffer: TcpBuffer = Buffer.alloc(0);
private nextRequestId = 1;
private pendingRequests = new Map<string, PendingGatewayTcpRequest>();
private negotiatedMaxPendingRequests: number | null = null;
private resolveHelloAck: (() => void) | null = null;
private rejectHelloAck: ((error: Error) => void) | null = null;
private pingTimer: NodeJS.Timeout | null = null;
private destroyed = false;
private helloAcknowledged = false;
constructor(options: GatewayTcpRpcTransportOptions) {
this.options = options;
this.maxFrameBytes = options.maxFrameBytes ?? MAX_GATEWAY_TCP_FRAME_BYTES;
this.maxPendingRequests = Math.max(1, options.maxPendingRequests ?? 1024);
this.maxBufferBytes = Math.max(1, options.maxBufferBytes ?? this.maxFrameBytes * 2);
}
async call(method: string, params: Record<string, unknown>): Promise<unknown> {
if (this.destroyed) {
throw new GatewayTcpTransportError('Gateway TCP transport is destroyed');
}
await this.ensureConnected();
const maxPendingRequests = this.getEffectiveMaxPendingRequests();
if (this.pendingRequests.size >= maxPendingRequests) {
throw new GatewayTcpTransportError(
`Gateway TCP request queue is full (pending=${this.pendingRequests.size}, limit=${maxPendingRequests})`,
);
}
const requestId = `${this.nextRequestId}`;
this.nextRequestId += 1;
const responsePromise = new Promise<unknown>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(requestId);
const error = new GatewayTcpTransportError('Gateway TCP request timed out');
reject(error);
this.closeSocket(error);
}, this.options.requestTimeoutMs);
this.pendingRequests.set(requestId, {resolve, reject, timeout});
});
const frame: GatewayTcpRequestFrame = {
type: 'request',
id: requestId,
method,
params,
};
try {
await this.sendFrame(frame);
} catch (error) {
const pending = this.pendingRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(requestId);
pending.reject(this.toTransportError(error, 'Gateway TCP send failed'));
}
throw this.toTransportError(error, 'Gateway TCP send failed');
}
return responsePromise;
}
async destroy(): Promise<void> {
this.destroyed = true;
this.closeSocket(new GatewayTcpTransportError('Gateway TCP transport destroyed'));
if (this.connectPromise) {
try {
await this.connectPromise;
} catch {}
}
}
private async ensureConnected(): Promise<void> {
if (this.socket && !this.socket.destroyed && this.helloAcknowledged) {
return;
}
if (this.connectPromise) {
await this.connectPromise;
return;
}
this.connectPromise = this.openConnection();
try {
await this.connectPromise;
} finally {
this.connectPromise = null;
}
}
private async openConnection(): Promise<void> {
const socket = createConnection({
host: this.options.host,
port: this.options.port,
});
socket.setNoDelay(true);
socket.setKeepAlive(true);
socket.on('data', (data: Buffer) => {
this.handleSocketData(socket, data);
});
socket.on('error', (error: Error) => {
this.handleSocketError(socket, error);
});
socket.on('close', () => {
this.handleSocketClose(socket);
});
await this.waitForSocketConnect(socket);
this.socket = socket;
this.buffer = Buffer.alloc(0);
this.helloAcknowledged = false;
const helloAckPromise = new Promise<void>((resolve, reject) => {
this.resolveHelloAck = resolve;
this.rejectHelloAck = reject;
});
const helloFrame: GatewayTcpHelloFrame = {
type: 'hello',
protocol: GATEWAY_TCP_PROTOCOL,
authorization: this.options.authorization,
};
await this.sendFrame(helloFrame);
await helloAckPromise;
}
private async waitForSocketConnect(socket: Socket): Promise<void> {
await new Promise<void>((resolve, reject) => {
let settled = false;
const onConnect = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutHandle);
socket.off('error', onError);
resolve();
};
const onError = (error: Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutHandle);
socket.off('connect', onConnect);
reject(new GatewayTcpTransportError(error.message));
};
const timeoutHandle = setTimeout(() => {
if (settled) {
return;
}
settled = true;
socket.off('connect', onConnect);
socket.off('error', onError);
socket.destroy();
reject(new GatewayTcpTransportError('Gateway TCP connect timeout'));
}, this.options.connectTimeoutMs);
socket.once('connect', onConnect);
socket.once('error', onError);
});
}
private handleSocketData(socket: Socket, data: TcpBuffer): void {
if (this.socket !== socket) {
return;
}
const nextBufferSize = this.buffer.length + data.length;
if (nextBufferSize > this.maxBufferBytes) {
this.options.logger.warn(
{
bufferBytes: nextBufferSize,
maxBufferBytes: this.maxBufferBytes,
},
'[gateway-rpc-tcp] input buffer limit exceeded',
);
this.closeSocket(
new GatewayTcpTransportError(
`Gateway TCP input buffer exceeded maximum size (buffer_bytes=${nextBufferSize}, max_buffer_bytes=${this.maxBufferBytes})`,
),
);
return;
}
this.buffer = Buffer.concat([this.buffer, data]);
let decodedFrames: ReturnType<typeof decodeGatewayTcpFrames>;
try {
decodedFrames = decodeGatewayTcpFrames(this.buffer, this.maxFrameBytes);
} catch (error) {
const transportError = this.toTransportError(error, 'Gateway TCP protocol decode failed');
this.closeSocket(transportError);
return;
}
this.buffer = decodedFrames.remainder;
for (const frame of decodedFrames.frames) {
this.handleIncomingFrame(frame);
}
}
private handleIncomingFrame(frame: unknown): void {
if (!frame || typeof frame !== 'object') {
this.closeSocket(new GatewayTcpTransportError('Gateway TCP frame is not an object'));
return;
}
const frameMap = frame as Record<string, unknown>;
if (isHelloAckFrame(frameMap)) {
this.handleHelloAckFrame(frameMap);
return;
}
if (isResponseFrame(frameMap)) {
this.handleResponseFrame(frameMap);
return;
}
if (isErrorFrame(frameMap)) {
const errorFrame = frameMap;
const message =
typeof errorFrame.error === 'string' && errorFrame.error.length > 0
? errorFrame.error
: 'Gateway TCP returned an error frame';
this.closeSocket(new GatewayTcpTransportError(message));
return;
}
if (frameMap.type === 'ping') {
void this.sendFrame({type: 'pong'} satisfies GatewayTcpPingFrame).catch((error) => {
this.closeSocket(this.toTransportError(error, 'Gateway TCP pong send failed'));
});
return;
}
if (frameMap.type === 'pong') {
return;
}
this.closeSocket(new GatewayTcpTransportError('Gateway TCP received unknown frame type'));
}
private handleHelloAckFrame(frame: GatewayTcpHelloAckFrame): void {
if (frame.protocol !== GATEWAY_TCP_PROTOCOL) {
this.closeSocket(new GatewayTcpTransportError('Gateway TCP protocol mismatch'));
return;
}
this.negotiatedMaxPendingRequests = this.resolveNegotiatedMaxPendingRequests(frame.max_in_flight);
this.helloAcknowledged = true;
this.startPingTimer(frame.ping_interval_ms);
if (this.resolveHelloAck) {
this.resolveHelloAck();
this.resolveHelloAck = null;
this.rejectHelloAck = null;
}
}
private handleResponseFrame(frame: GatewayTcpResponseFrame): void {
if (typeof frame.id !== 'string') {
this.closeSocket(new GatewayTcpTransportError('Gateway TCP response missing request id'));
return;
}
const pending = this.pendingRequests.get(frame.id);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
this.pendingRequests.delete(frame.id);
if (frame.ok) {
pending.resolve(frame.result);
return;
}
if (typeof frame.error === 'string' && frame.error.length > 0) {
pending.reject(new GatewayRpcMethodError(frame.error));
return;
}
pending.reject(new Error('Gateway RPC request failed'));
}
private async sendFrame(frame: unknown): Promise<void> {
const socket = this.socket;
if (!socket || socket.destroyed) {
throw new GatewayTcpTransportError('Gateway TCP socket is not connected');
}
const encoded = encodeGatewayTcpFrame(frame, this.maxFrameBytes);
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
socket.off('drain', onDrain);
reject(error);
};
const onDrain = () => {
socket.off('error', onError);
resolve();
};
socket.once('error', onError);
const canWrite = socket.write(encoded, () => {
if (canWrite) {
socket.off('error', onError);
resolve();
}
});
if (!canWrite) {
socket.once('drain', onDrain);
}
});
}
private startPingTimer(pingIntervalMs?: number): void {
this.stopPingTimer();
const intervalMs = pingIntervalMs ?? this.options.defaultPingIntervalMs;
this.pingTimer = setInterval(() => {
void this.sendFrame({type: 'ping'} satisfies GatewayTcpPingFrame).catch((error) => {
this.closeSocket(this.toTransportError(error, 'Gateway TCP ping failed'));
});
}, intervalMs);
}
private stopPingTimer(): void {
if (!this.pingTimer) {
return;
}
clearInterval(this.pingTimer);
this.pingTimer = null;
}
private handleSocketError(socket: Socket, error: Error): void {
if (this.socket !== socket) {
return;
}
const transportError = this.toTransportError(error, 'Gateway TCP socket error');
this.options.logger.warn({error: transportError}, '[gateway-rpc-tcp] socket error');
this.closeSocket(transportError);
}
private handleSocketClose(socket: Socket): void {
if (this.socket !== socket) {
return;
}
this.closeSocket(new GatewayTcpTransportError('Gateway TCP socket closed'));
}
private closeSocket(error: Error): void {
this.stopPingTimer();
this.helloAcknowledged = false;
this.buffer = Buffer.alloc(0);
this.negotiatedMaxPendingRequests = null;
if (this.rejectHelloAck) {
this.rejectHelloAck(error);
this.resolveHelloAck = null;
this.rejectHelloAck = null;
}
for (const pending of this.pendingRequests.values()) {
clearTimeout(pending.timeout);
pending.reject(error);
}
this.pendingRequests.clear();
const socket = this.socket;
this.socket = null;
if (socket && !socket.destroyed) {
socket.destroy();
}
}
private toTransportError(error: unknown, fallbackMessage: string): GatewayTcpTransportError {
if (error instanceof GatewayTcpTransportError) {
return error;
}
if (error instanceof Error && error.message.length > 0) {
return new GatewayTcpTransportError(error.message);
}
return new GatewayTcpTransportError(fallbackMessage);
}
private getEffectiveMaxPendingRequests(): number {
if (this.negotiatedMaxPendingRequests === null) {
return this.maxPendingRequests;
}
return this.negotiatedMaxPendingRequests;
}
private resolveNegotiatedMaxPendingRequests(maxInFlight: unknown): number | null {
if (typeof maxInFlight !== 'number' || !Number.isInteger(maxInFlight) || maxInFlight <= 0) {
return null;
}
return Math.min(this.maxPendingRequests, maxInFlight);
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface QueuedAssetDeletion {
s3Key: string;
cdnUrl: string | null;
reason: string;
queuedAt?: number;
retryCount?: number;
}
export interface DeletionQueueProcessResult {
deleted: number;
requeued: number;
failed: number;
remaining: number;
}
export interface IAssetDeletionQueue {
queueDeletion(item: Omit<QueuedAssetDeletion, 'queuedAt' | 'retryCount'>): Promise<void>;
queueCdnPurge(cdnUrl: string): Promise<void>;
getBatch(count: number): Promise<Array<QueuedAssetDeletion>>;
requeueItem(item: QueuedAssetDeletion): Promise<void>;
getQueueSize(): Promise<number>;
clear(): Promise<void>;
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface IGatewayRpcTransport {
call(method: string, params: Record<string, unknown>): Promise<unknown>;
destroy(): Promise<void>;
}

View File

@@ -0,0 +1,271 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, RoleID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {GatewayDispatchEvent} from '@fluxer/api/src/constants/Gateway';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
interface VoiceState {
user_id: string;
session_id: string;
self_mute: boolean;
self_deaf: boolean;
self_video: boolean;
viewer_stream_keys?: Array<string>;
}
export interface CallData {
channel_id: string;
message_id: string;
region: string;
ringing: Array<string>;
recipients: Array<string>;
voice_states: Array<VoiceState>;
}
export abstract class IGatewayService {
abstract dispatchGuild(params: {guildId: GuildID; event: GatewayDispatchEvent; data: unknown}): Promise<void>;
abstract getGuildCounts(guildId: GuildID): Promise<{memberCount: number; presenceCount: number}>;
abstract getChannelCount(params: {guildId: GuildID}): Promise<number>;
abstract startGuild(guildId: GuildID): Promise<void>;
abstract stopGuild(guildId: GuildID): Promise<void>;
abstract reloadGuild(guildId: GuildID): Promise<void>;
abstract reloadAllGuilds(guildIds: Array<GuildID>): Promise<{count: number}>;
abstract shutdownGuild(guildId: GuildID): Promise<void>;
abstract getGuildMemoryStats(limit: number): Promise<{
guilds: Array<{
guild_id: string | null;
guild_name: string;
guild_icon: string | null;
memory: string;
member_count: number;
session_count: number;
presence_count: number;
}>;
}>;
abstract getUsersToMentionByRoles(params: {
guildId: GuildID;
channelId: ChannelID;
roleIds: Array<RoleID>;
authorId: UserID;
}): Promise<Array<UserID>>;
abstract getUsersToMentionByUserIds(params: {
guildId: GuildID;
channelId: ChannelID;
userIds: Array<UserID>;
authorId: UserID;
}): Promise<Array<UserID>>;
abstract getAllUsersToMention(params: {
guildId: GuildID;
channelId: ChannelID;
authorId: UserID;
}): Promise<Array<UserID>>;
abstract resolveAllMentions(params: {
guildId: GuildID;
channelId: ChannelID;
authorId: UserID;
mentionEveryone: boolean;
mentionHere: boolean;
roleIds: Array<RoleID>;
userIds: Array<UserID>;
}): Promise<Array<UserID>>;
abstract getUserPermissions(params: {guildId: GuildID; userId: UserID; channelId?: ChannelID}): Promise<bigint>;
abstract getUserPermissionsBatch(params: {
guildIds: Array<GuildID>;
userId: UserID;
channelId?: ChannelID;
}): Promise<Map<GuildID, bigint>>;
abstract canManageRoles(params: {
guildId: GuildID;
userId: UserID;
targetUserId: UserID;
roleId: RoleID;
}): Promise<boolean>;
abstract canManageRole(params: {guildId: GuildID; userId: UserID; roleId: RoleID}): Promise<boolean>;
abstract getAssignableRoles(params: {guildId: GuildID; userId: UserID}): Promise<Array<RoleID>>;
abstract getUserMaxRolePosition(params: {guildId: GuildID; userId: UserID}): Promise<number>;
abstract checkTargetMember(params: {guildId: GuildID; userId: UserID; targetUserId: UserID}): Promise<boolean>;
abstract getViewableChannels(params: {guildId: GuildID; userId: UserID}): Promise<Array<ChannelID>>;
abstract getCategoryChannelCount(params: {guildId: GuildID; categoryId: ChannelID}): Promise<number>;
abstract getMembersWithRole(params: {guildId: GuildID; roleId: RoleID}): Promise<Array<UserID>>;
abstract getGuildData(params: {
guildId: GuildID;
userId: UserID;
skipMembershipCheck?: boolean;
}): Promise<GuildResponse>;
abstract getGuildMember(params: {
guildId: GuildID;
userId: UserID;
}): Promise<{success: boolean; memberData?: GuildMemberResponse}>;
abstract hasGuildMember(params: {guildId: GuildID; userId: UserID}): Promise<boolean>;
abstract listGuildMembers(params: {guildId: GuildID; limit: number; offset: number}): Promise<{
members: Array<GuildMemberResponse>;
total: number;
}>;
abstract listGuildMembersCursor(params: {guildId: GuildID; limit: number; after?: UserID}): Promise<{
members: Array<GuildMemberResponse>;
total: number;
}>;
abstract checkPermission(params: {
guildId: GuildID;
userId: UserID;
permission: bigint;
channelId?: ChannelID;
}): Promise<boolean>;
abstract getVanityUrlChannel(guildId: GuildID): Promise<ChannelID | null>;
abstract getFirstViewableTextChannel(guildId: GuildID): Promise<ChannelID | null>;
abstract dispatchPresence(params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void>;
abstract invalidatePushBadgeCount(params: {userId: UserID}): Promise<void>;
abstract joinGuild(params: {userId: UserID; guildId: GuildID}): Promise<void>;
abstract leaveGuild(params: {userId: UserID; guildId: GuildID}): Promise<void>;
abstract terminateSession(params: {userId: UserID; sessionIdHashes: Array<string>}): Promise<void>;
abstract terminateAllSessionsForUser(params: {userId: UserID}): Promise<void>;
abstract updateMemberVoice(params: {
guildId: GuildID;
userId: UserID;
mute: boolean;
deaf: boolean;
}): Promise<{success: boolean}>;
abstract disconnectVoiceUser(params: {guildId: GuildID; userId: UserID; connectionId: string}): Promise<void>;
abstract disconnectVoiceUserIfInChannel(params: {
guildId?: GuildID;
channelId: ChannelID;
userId: UserID;
connectionId?: string;
}): Promise<{success: boolean; ignored?: boolean}>;
abstract disconnectAllVoiceUsersInChannel(params: {
guildId: GuildID;
channelId: ChannelID;
}): Promise<{success: boolean; disconnectedCount: number}>;
abstract confirmVoiceConnection(params: {
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
tokenNonce: string;
}): Promise<{success: boolean; error?: string}>;
abstract getVoiceStatesForChannel(params: {
guildId?: GuildID;
channelId: ChannelID;
}): Promise<{voiceStates: Array<{connectionId: string; userId: string; channelId: string}>}>;
abstract getPendingJoinsForChannel(params: {
guildId?: GuildID;
channelId: ChannelID;
}): Promise<{pendingJoins: Array<{connectionId: string; userId: string; tokenNonce: string; expiresAt: number}>}>;
abstract getVoiceState(params: {guildId: GuildID; userId: UserID}): Promise<{channel_id: string | null} | null>;
abstract moveMember(params: {
guildId: GuildID;
moderatorId: UserID;
userId: UserID;
channelId: ChannelID | null;
connectionId: string | null;
}): Promise<{
success?: boolean;
error?: string;
}>;
abstract hasActivePresence(userId: UserID): Promise<boolean>;
abstract addTemporaryGuild(params: {userId: UserID; guildId: GuildID}): Promise<void>;
abstract removeTemporaryGuild(params: {userId: UserID; guildId: GuildID}): Promise<void>;
abstract syncGroupDmRecipients(params: {
userId: UserID;
recipientsByChannel: Record<string, Array<string>>;
}): Promise<void>;
abstract switchVoiceRegion(params: {guildId: GuildID; channelId: ChannelID}): Promise<void>;
abstract getCall(channelId: ChannelID): Promise<CallData | null>;
abstract createCall(
channelId: ChannelID,
messageId: string,
region: string,
ringing: Array<string>,
recipients: Array<string>,
): Promise<CallData>;
abstract updateCallRegion(channelId: ChannelID, region: string | null): Promise<boolean>;
abstract ringCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean>;
abstract stopRingingCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean>;
abstract deleteCall(channelId: ChannelID): Promise<boolean>;
abstract getDiscoveryOnlineCounts(guildIds: Array<GuildID>): Promise<Map<GuildID, number>>;
abstract getNodeStats(): Promise<{
status: string;
sessions: number;
guilds: number;
presences: number;
calls: number;
memory: {
total: string;
processes: string;
system: string;
};
process_count: number;
process_limit: number;
uptime_seconds: number;
}>;
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {KlipyCategoryTagResponse, KlipyGifResponse} from '@fluxer/schema/src/domains/klipy/KlipySchemas';
export interface IKlipyService {
search(params: {q: string; locale: string; country: string}): Promise<Array<KlipyGifResponse>>;
registerShare(params: {id: string; q: string; locale: string; country: string}): Promise<void>;
getFeatured(params: {locale: string; country: string}): Promise<{
gifs: Array<KlipyGifResponse>;
categories: Array<KlipyCategoryTagResponse>;
}>;
getTrendingGifs(params: {locale: string; country: string}): Promise<Array<KlipyGifResponse>>;
suggest(params: {q: string; locale: string}): Promise<Array<string>>;
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {VoiceRegionMetadata, VoiceServerRecord} from '@fluxer/api/src/voice/VoiceModel';
interface CreateTokenParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
tokenNonce: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
canSpeak?: boolean;
canStream?: boolean;
canVideo?: boolean;
}
interface UpdateParticipantParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
}
interface UpdateParticipantPermissionsParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
canSpeak: boolean;
canStream: boolean;
canVideo: boolean;
}
interface DisconnectParticipantParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
}
interface ListParticipantsParams {
guildId?: GuildID;
channelId: ChannelID;
regionId: string;
serverId: string;
}
export interface ListParticipantsSuccess {
status: 'ok';
participants: Array<{identity: string}>;
}
export interface ListParticipantsError {
status: 'error';
errorCode: string;
retryable: boolean;
}
export type ListParticipantsResult = ListParticipantsSuccess | ListParticipantsError;
export abstract class ILiveKitService {
abstract createToken(params: CreateTokenParams): Promise<{token: string; endpoint: string}>;
abstract updateParticipant(params: UpdateParticipantParams): Promise<void>;
abstract updateParticipantPermissions(params: UpdateParticipantPermissionsParams): Promise<void>;
abstract disconnectParticipant(params: DisconnectParticipantParams): Promise<void>;
abstract listParticipants(params: ListParticipantsParams): Promise<ListParticipantsResult>;
abstract getDefaultRegionId(): string | null;
abstract getRegionMetadata(): Array<VoiceRegionMetadata>;
abstract getServer(regionId: string, serverId: string): VoiceServerRecord | null;
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export type MediaProxyMetadataRequest =
| {
type: 'external';
url: string;
with_base64?: boolean;
isNSFWAllowed: boolean;
}
| {
type: 'upload';
upload_filename: string;
filename?: string;
isNSFWAllowed: boolean;
}
| {
type: 'base64';
base64: string;
isNSFWAllowed: boolean;
}
| {
type: 's3';
bucket: string;
key: string;
with_base64?: boolean;
isNSFWAllowed: boolean;
};
export interface MediaProxyMetadataResponse {
format: string;
content_type: string;
content_hash: string;
size: number;
width?: number;
height?: number;
duration?: number;
placeholder?: string;
base64?: string;
animated?: boolean;
nsfw: boolean;
nsfw_probability?: number;
nsfw_predictions?: Record<string, number>;
}
export type MediaProxyFrameRequest =
| {type: 'upload'; upload_filename: string}
| {type: 's3'; bucket: string; key: string};
export interface MediaProxyFrameData {
timestamp: number;
mime_type: string;
base64: string;
}
export interface MediaProxyFrameResponse {
frames: Array<MediaProxyFrameData>;
}
export abstract class IMediaService {
abstract getMetadata(request: MediaProxyMetadataRequest): Promise<MediaProxyMetadataResponse | null>;
abstract getExternalMediaProxyURL(url: string): string;
abstract getThumbnail(uploadFilename: string): Promise<Buffer | null>;
abstract extractFrames(request: MediaProxyFrameRequest): Promise<MediaProxyFrameResponse>;
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface CounterParams {
name: string;
dimensions?: Record<string, string>;
value?: number;
}
export interface GaugeParams {
name: string;
dimensions?: Record<string, string>;
value: number;
}
export interface HistogramParams {
name: string;
dimensions?: Record<string, string>;
valueMs: number;
}
export interface CrashParams {
guildId: string | null;
stacktrace: string;
}
export interface BatchMetric {
type: 'counter' | 'gauge' | 'histogram';
name: string;
dimensions?: Record<string, string>;
value?: number;
valueMs?: number;
}
export interface IMetricsService {
counter(params: CounterParams): void;
gauge(params: GaugeParams): void;
histogram(params: HistogramParams): void;
crash(params: CrashParams): void;
batch(metrics: Array<BatchMetric>): void;
isEnabled(): boolean;
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface ISnowflakeService {
initialize(): Promise<void>;
reinitialize(): Promise<void>;
shutdown(): Promise<void>;
generate(): Promise<bigint>;
getNodeIdForTesting(): number | null;
renewNodeIdForTesting(): Promise<void>;
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Readable} from 'node:stream';
export interface IStorageService {
uploadObject(params: {
bucket: string;
key: string;
body: Uint8Array;
contentType?: string;
expiresAt?: Date;
}): Promise<void>;
deleteObject(bucket: string, key: string): Promise<void>;
getObjectMetadata(bucket: string, key: string): Promise<{contentLength: number; contentType: string} | null>;
readObject(bucket: string, key: string): Promise<Uint8Array>;
streamObject(params: {bucket: string; key: string; range?: string}): Promise<{
body: Readable;
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null>;
writeObjectToDisk(bucket: string, key: string, filePath: string): Promise<void>;
copyObject(params: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise<void>;
copyObjectWithJpegProcessing(params: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
contentType: string;
}): Promise<{width: number; height: number} | null>;
moveObject(params: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise<void>;
getPresignedDownloadURL(params: {bucket: string; key: string; expiresIn?: number}): Promise<string>;
purgeBucket(bucket: string): Promise<void>;
uploadAvatar(params: {prefix: string; key: string; body: Uint8Array}): Promise<void>;
deleteAvatar(params: {prefix: string; key: string}): Promise<void>;
listObjects(params: {bucket: string; prefix: string}): Promise<
ReadonlyArray<{
key: string;
lastModified?: Date;
}>
>;
deleteObjects(params: {bucket: string; objects: ReadonlyArray<{Key: string}>}): Promise<void>;
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {TenorCategoryTagResponse, TenorGifResponse} from '@fluxer/schema/src/domains/tenor/TenorSchemas';
export interface ITenorService {
search(params: {q: string; locale: string; country: string}): Promise<Array<TenorGifResponse>>;
registerShare(params: {id: string; q: string; locale: string; country: string}): Promise<void>;
getFeatured(params: {locale: string; country: string}): Promise<{
gifs: Array<TenorGifResponse>;
categories: Array<TenorCategoryTagResponse>;
}>;
getTrendingGifs(params: {locale: string; country: string}): Promise<Array<TenorGifResponse>>;
suggest(params: {q: string; locale: string}): Promise<Array<string>>;
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
export abstract class IUnfurlerService {
abstract unfurl(url: string, isNSFWAllowed?: boolean): Promise<Array<MessageEmbedResponse>>;
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID} from '@fluxer/api/src/BrandedTypes';
export abstract class IVoiceRoomStore {
abstract pinRoomServer(
guildId: GuildID | undefined,
channelId: ChannelID,
regionId: string,
serverId: string,
endpoint: string,
): Promise<void>;
abstract getPinnedRoomServer(
guildId: GuildID | undefined,
channelId: ChannelID,
): Promise<{regionId: string; serverId: string; endpoint: string} | null>;
abstract deleteRoomServer(guildId: GuildID | undefined, channelId: ChannelID): Promise<void>;
abstract getRegionOccupancy(regionId: string): Promise<Array<string>>;
abstract getServerOccupancy(regionId: string, serverId: string): Promise<Array<string>>;
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID} from '@fluxer/api/src/BrandedTypes';
export class InMemoryVoiceRoomStore {
async pinRoomServer(
_guildId: GuildID | undefined,
_channelId: ChannelID,
_regionId: string,
_serverId: string,
_endpoint: string,
): Promise<void> {}
async getPinnedRoomServer(
_guildId: GuildID | undefined,
_channelId: ChannelID,
): Promise<{regionId: string; serverId: string; endpoint: string} | null> {
return null;
}
async deleteRoomServer(_guildId: GuildID | undefined, _channelId: ChannelID): Promise<void> {}
async getRegionOccupancy(_regionId: string): Promise<Array<string>> {
return [];
}
async getServerOccupancy(_regionId: string, _serverId: string): Promise<Array<string>> {
return [];
}
}

View File

@@ -0,0 +1,262 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {Logger} from '@fluxer/api/src/Logger';
import type {UserRepository} from '@fluxer/api/src/user/repositories/UserRepository';
import {generateLockToken} from '@fluxer/cache/src/CacheLockValidation';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
import {ms, seconds} from 'itty-time';
interface QueuedDeletion {
userId: bigint;
deletionReasonCode: number;
}
const QUEUE_KEY = 'deletion_queue';
const STATE_VERSION_KEY = 'deletion_queue:state_version';
const REBUILD_LOCK_KEY = 'deletion_queue:rebuild_lock';
const REBUILD_LOCK_TTL = seconds('5 minutes');
export class KVAccountDeletionQueueService {
constructor(
private readonly kvClient: IKVProvider,
private readonly userRepository: UserRepository,
) {}
private serializeQueueItem(item: QueuedDeletion): string {
return `${item.userId}|${item.deletionReasonCode}`;
}
private deserializeQueueItem(value: string): QueuedDeletion {
const parts = value.split('|');
return {
userId: BigInt(parts[0]),
deletionReasonCode: parseInt(parts[1], 10),
};
}
async needsRebuild(): Promise<boolean> {
try {
const versionExists = await this.kvClient.exists(STATE_VERSION_KEY);
if (!versionExists) {
Logger.debug('Deletion queue needs rebuild: no state version');
return true;
}
const stateVersionStr = await this.kvClient.get(STATE_VERSION_KEY);
if (stateVersionStr) {
const stateVersion = parseInt(stateVersionStr, 10);
const ageMs = Date.now() - stateVersion;
if (ageMs > ms('1 day')) {
Logger.debug({ageMs, maxAgeMs: ms('1 day')}, 'Deletion queue needs rebuild: state too old');
return true;
}
}
return false;
} catch (error) {
Logger.error({error}, 'Failed to check if deletion queue needs rebuild');
throw error;
}
}
async rebuildState(): Promise<void> {
Logger.info('Starting deletion queue rebuild from Cassandra');
try {
await this.kvClient.del(QUEUE_KEY);
await this.kvClient.del(STATE_VERSION_KEY);
let lastUserId: UserID | undefined;
let totalProcessed = 0;
let totalQueued = 0;
const batchSize = 1000;
while (true) {
const users = await this.userRepository.listAllUsersPaginated(batchSize, lastUserId);
if (users.length === 0) {
break;
}
const pipeline = this.kvClient.pipeline();
let batchQueued = 0;
for (const user of users) {
if (user.pendingDeletionAt) {
const queueItem: QueuedDeletion = {
userId: user.id,
deletionReasonCode: user.deletionReasonCode ?? 0,
};
const score = user.pendingDeletionAt.getTime();
const value = this.serializeQueueItem(queueItem);
const secondaryKey = this.getSecondaryKey(user.id);
pipeline.zadd(QUEUE_KEY, score, value);
pipeline.set(secondaryKey, value);
batchQueued++;
}
}
if (batchQueued > 0) {
await pipeline.exec();
totalQueued += batchQueued;
}
totalProcessed += users.length;
lastUserId = users[users.length - 1].id;
if (totalProcessed % 10000 === 0) {
Logger.debug({totalProcessed, totalQueued}, 'Deletion queue rebuild progress');
}
}
await this.kvClient.set(STATE_VERSION_KEY, Date.now().toString());
Logger.info({totalProcessed, totalQueued}, 'Deletion queue rebuild completed');
} catch (error) {
Logger.error({error}, 'Failed to rebuild deletion queue state');
throw error;
}
}
async scheduleDeletion(userId: UserID, pendingAt: Date, reasonCode: number): Promise<void> {
try {
const queueItem: QueuedDeletion = {
userId,
deletionReasonCode: reasonCode,
};
const score = pendingAt.getTime();
const value = this.serializeQueueItem(queueItem);
const secondaryKey = this.getSecondaryKey(userId);
const pipeline = this.kvClient.pipeline();
pipeline.zadd(QUEUE_KEY, score, value);
pipeline.set(secondaryKey, value);
await pipeline.exec();
Logger.debug({userId: userId.toString(), pendingAt, reasonCode}, 'Scheduled user deletion');
} catch (error) {
Logger.error({error, userId: userId.toString()}, 'Failed to schedule deletion');
throw error;
}
}
async removeFromQueue(userId: UserID): Promise<void> {
try {
const secondaryKey = this.getSecondaryKey(userId);
const value = await this.kvClient.get(secondaryKey);
if (!value) {
Logger.debug({userId: userId.toString()}, 'User not in deletion queue');
return;
}
const pipeline = this.kvClient.pipeline();
pipeline.zrem(QUEUE_KEY, value);
pipeline.del(secondaryKey);
await pipeline.exec();
Logger.debug({userId: userId.toString()}, 'Removed user from deletion queue');
} catch (error) {
Logger.error({error, userId: userId.toString()}, 'Failed to remove user from deletion queue');
throw error;
}
}
async getReadyDeletions(nowMs: number, limit: number): Promise<Array<QueuedDeletion>> {
try {
const results = await this.kvClient.zrangebyscore(QUEUE_KEY, '-inf', nowMs, 'LIMIT', 0, limit);
const deletions: Array<QueuedDeletion> = [];
for (const result of results) {
try {
const deletion = this.deserializeQueueItem(result);
deletions.push(deletion);
} catch (parseError) {
Logger.error({error: parseError, result}, 'Failed to parse queued deletion');
}
}
return deletions;
} catch (error) {
Logger.error({error, nowMs, limit}, 'Failed to get ready deletions');
throw error;
}
}
async acquireRebuildLock(): Promise<string | null> {
try {
const token = generateLockToken();
const result = await this.kvClient.set(REBUILD_LOCK_KEY, token, 'EX', REBUILD_LOCK_TTL, 'NX');
if (result === 'OK') {
Logger.debug({token}, 'Acquired rebuild lock');
return token;
}
return null;
} catch (error) {
Logger.error({error}, 'Failed to acquire rebuild lock');
throw error;
}
}
async releaseRebuildLock(token: string): Promise<boolean> {
try {
const released = await this.kvClient.releaseLock(REBUILD_LOCK_KEY, token);
if (released) {
Logger.debug({token}, 'Released rebuild lock');
}
return released;
} catch (error) {
Logger.error({error, token}, 'Failed to release rebuild lock');
throw error;
}
}
async getQueueSize(): Promise<number> {
try {
return await this.kvClient.zcard(QUEUE_KEY);
} catch (error) {
Logger.error({error}, 'Failed to get queue size');
throw error;
}
}
async getStateVersion(): Promise<number | null> {
try {
const versionStr = await this.kvClient.get(STATE_VERSION_KEY);
return versionStr ? parseInt(versionStr, 10) : null;
} catch (error) {
Logger.error({error}, 'Failed to get state version');
throw error;
}
}
private getSecondaryKey(userId: UserID): string {
return `deletion_queue_by_user:${userId.toString()}`;
}
}

View File

@@ -0,0 +1,153 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {Logger} from '@fluxer/api/src/Logger';
import {UserRepository} from '@fluxer/api/src/user/repositories/UserRepository';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
import {seconds} from 'itty-time';
const TTL_SECONDS = seconds('90 days');
const STATE_VERSION_KEY = 'activity_tracker:state_version';
const STATE_VERSION_TTL_SECONDS = seconds('1 day');
const REBUILD_BATCH_SIZE = 100;
export class KVActivityTracker {
private kvClient: IKVProvider;
private isShuttingDown = false;
constructor(kvClient: IKVProvider) {
this.kvClient = kvClient;
}
shutdown(): void {
this.isShuttingDown = true;
}
private getActivityKey(userId: UserID): string {
return `user_activity:${userId}`;
}
async updateActivity(userId: UserID, timestamp: Date): Promise<void> {
const key = this.getActivityKey(userId);
const value = timestamp.getTime().toString();
await this.kvClient.setex(key, TTL_SECONDS, value);
}
async getActivity(userId: UserID): Promise<Date | null> {
const key = this.getActivityKey(userId);
const value = await this.kvClient.get(key);
if (!value) {
return null;
}
const timestamp = parseInt(value, 10);
if (Number.isNaN(timestamp)) {
return null;
}
return new Date(timestamp);
}
async needsRebuild(): Promise<boolean> {
const exists = await this.kvClient.exists(STATE_VERSION_KEY);
if (exists === 0) {
return true;
}
const ttl = await this.kvClient.ttl(STATE_VERSION_KEY);
if (ttl < 0) {
return true;
}
const age = STATE_VERSION_TTL_SECONDS - ttl;
return age > STATE_VERSION_TTL_SECONDS;
}
async rebuildActivities(): Promise<void> {
Logger.info('Starting activity tracker rebuild from Cassandra');
const userRepository = new UserRepository();
try {
const kvBatchSize = 1000;
let processedCount = 0;
let usersWithActivity = 0;
let pipeline = this.kvClient.pipeline();
let pipelineCount = 0;
let lastUserId: UserID | undefined;
let iterationCount = 0;
while (!this.isShuttingDown) {
const users = await userRepository.listAllUsersPaginated(REBUILD_BATCH_SIZE, lastUserId);
if (users.length === 0) {
break;
}
for (const user of users) {
if (user.lastActiveAt) {
const key = this.getActivityKey(user.id);
const value = user.lastActiveAt.getTime().toString();
pipeline.setex(key, TTL_SECONDS, value);
pipelineCount++;
usersWithActivity++;
if (pipelineCount >= kvBatchSize) {
await pipeline.exec();
pipeline = this.kvClient.pipeline();
pipelineCount = 0;
}
}
processedCount++;
}
if (processedCount % 10000 === 0) {
Logger.info({processedCount, usersWithActivity}, 'Activity tracker rebuild progress');
}
lastUserId = users[users.length - 1].id;
iterationCount++;
if (iterationCount % 10 === 0) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
if (this.isShuttingDown) {
Logger.warn({processedCount, usersWithActivity}, 'Activity tracker rebuild interrupted by shutdown');
return;
}
if (pipelineCount > 0) {
await pipeline.exec();
}
await this.kvClient.setex(STATE_VERSION_KEY, STATE_VERSION_TTL_SECONDS, Date.now().toString());
Logger.info({processedCount, usersWithActivity}, 'Activity tracker rebuild completed');
} catch (error) {
Logger.error({error}, 'Activity tracker rebuild failed');
throw error;
}
}
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {Logger} from '@fluxer/api/src/Logger';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
interface QueuedBulkMessageDeletion {
userId: bigint;
scheduledAt: number;
}
const QUEUE_KEY = 'bulk_message_deletion_queue';
const SECONDARY_KEY_PREFIX = 'bulk_message_deletion_queue:';
export class KVBulkMessageDeletionQueueService {
constructor(private readonly kvClient: IKVProvider) {}
private getSecondaryKey(userId: UserID): string {
return `${SECONDARY_KEY_PREFIX}${userId}`;
}
private serializeQueueItem(item: QueuedBulkMessageDeletion): string {
return `${item.userId}|${item.scheduledAt}`;
}
private deserializeQueueItem(value: string): QueuedBulkMessageDeletion {
const [userIdStr, scheduledAtStr] = value.split('|');
return {
userId: BigInt(userIdStr),
scheduledAt: Number.parseInt(scheduledAtStr, 10),
};
}
async scheduleDeletion(userId: UserID, scheduledAt: Date): Promise<void> {
try {
const entry: QueuedBulkMessageDeletion = {
userId,
scheduledAt: scheduledAt.getTime(),
};
const value = this.serializeQueueItem(entry);
const secondaryKey = this.getSecondaryKey(userId);
await this.kvClient.scheduleBulkDeletion(QUEUE_KEY, secondaryKey, entry.scheduledAt, value);
Logger.debug({userId: userId.toString(), scheduledAt}, 'Scheduled bulk message deletion');
} catch (error) {
Logger.error({error, userId: userId.toString()}, 'Failed to schedule bulk message deletion');
throw error;
}
}
async removeFromQueue(userId: UserID): Promise<void> {
try {
const secondaryKey = this.getSecondaryKey(userId);
const removed = await this.kvClient.removeBulkDeletion(QUEUE_KEY, secondaryKey);
if (!removed) {
Logger.debug({userId: userId.toString()}, 'User not in bulk message deletion queue');
return;
}
Logger.debug({userId: userId.toString()}, 'Removed bulk message deletion from queue');
} catch (error) {
Logger.error({error, userId: userId.toString()}, 'Failed to remove bulk message deletion from queue');
throw error;
}
}
async getReadyDeletions(nowMs: number, limit: number): Promise<Array<QueuedBulkMessageDeletion>> {
try {
const results = await this.kvClient.zrangebyscore(QUEUE_KEY, '-inf', nowMs, 'LIMIT', 0, limit);
const deletions: Array<QueuedBulkMessageDeletion> = [];
for (const result of results) {
try {
const deletion = this.deserializeQueueItem(result);
deletions.push(deletion);
} catch (error) {
Logger.error({error, result}, 'Failed to parse queued bulk message deletion entry');
}
}
return deletions;
} catch (error) {
Logger.error({error, nowMs, limit}, 'Failed to fetch ready bulk message deletions');
throw error;
}
}
async getQueueSize(): Promise<number> {
try {
return await this.kvClient.zcard(QUEUE_KEY);
} catch (error) {
Logger.error({error}, 'Failed to get bulk message deletion queue size');
throw error;
}
}
}

View File

@@ -0,0 +1,403 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {ListParticipantsResult} from '@fluxer/api/src/infrastructure/ILiveKitService';
import {ILiveKitService} from '@fluxer/api/src/infrastructure/ILiveKitService';
import {Logger} from '@fluxer/api/src/Logger';
import type {VoiceRegionMetadata, VoiceServerRecord} from '@fluxer/api/src/voice/VoiceModel';
import type {VoiceTopology} from '@fluxer/api/src/voice/VoiceTopology';
import {AccessToken, RoomServiceClient, TrackSource, TrackType} from 'livekit-server-sdk';
interface CreateTokenParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
tokenNonce: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
canSpeak?: boolean;
canStream?: boolean;
canVideo?: boolean;
}
interface UpdateParticipantParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
}
interface DisconnectParticipantParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
}
interface UpdateParticipantPermissionsParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
canSpeak: boolean;
canStream: boolean;
canVideo: boolean;
}
interface ServerClientConfig {
endpoint: string;
apiKey: string;
apiSecret: string;
roomServiceClient: RoomServiceClient;
}
export function toHttpUrl(wsUrl: string): string {
return wsUrl.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://');
}
export function createRoomServiceClient(endpoint: string, apiKey: string, apiSecret: string): RoomServiceClient {
const httpUrl = toHttpUrl(endpoint);
const parsed = new URL(httpUrl);
const pathPrefix = parsed.pathname.replace(/\/+$/, '');
const client = new RoomServiceClient(parsed.origin, apiKey, apiSecret);
if (pathPrefix) {
const rpc = Reflect.get(client, 'rpc');
if (rpc != null && typeof rpc === 'object' && 'prefix' in rpc) {
Reflect.set(rpc, 'prefix', `${pathPrefix}${String(Reflect.get(rpc, 'prefix'))}`);
}
}
return client;
}
export class LiveKitService extends ILiveKitService {
private serverClients: Map<string, Map<string, ServerClientConfig>> = new Map();
private topology: VoiceTopology;
private static readonly DEFAULT_PUBLISH_SOURCES = [
TrackSource.CAMERA,
TrackSource.MICROPHONE,
TrackSource.SCREEN_SHARE,
TrackSource.SCREEN_SHARE_AUDIO,
];
constructor(topology: VoiceTopology) {
super();
if (!Config.voice.enabled) {
throw new Error('Voice is not enabled. Set VOICE_ENABLED=true to use voice features.');
}
this.topology = topology;
this.refreshServerClients();
this.topology.registerSubscriber(() => {
try {
this.refreshServerClients();
} catch (error) {
Logger.error({error}, 'Failed to refresh LiveKit server clients after topology update');
}
});
}
async createToken(params: CreateTokenParams): Promise<{token: string; endpoint: string}> {
const {
userId,
guildId,
channelId,
connectionId,
regionId,
serverId,
deaf = false,
canSpeak = true,
canStream = true,
canVideo = true,
} = params;
const server = this.resolveServerClient(regionId, serverId);
const roomName = this.getRoomName(guildId, channelId);
const participantIdentity = this.getParticipantIdentity(userId, connectionId);
const metadata: Record<string, string> = {
user_id: userId.toString(),
channel_id: channelId.toString(),
connection_id: connectionId,
region_id: regionId,
server_id: serverId,
};
metadata['token_nonce'] = params.tokenNonce;
metadata['issued_at'] = Math.floor(Date.now() / 1000).toString();
if (guildId !== undefined) {
metadata['guild_id'] = guildId.toString();
} else {
metadata['dm_call'] = 'true';
}
const canPublishSources = LiveKitService.computePublishSources({canSpeak, canStream, canVideo});
const accessToken = new AccessToken(server.apiKey, server.apiSecret, {
identity: participantIdentity,
metadata: JSON.stringify(metadata),
});
accessToken.addGrant({
roomJoin: true,
room: roomName,
canPublish: !deaf && canPublishSources.length > 0,
canSubscribe: !deaf,
canPublishSources,
});
const token = await accessToken.toJwt();
return {token, endpoint: server.endpoint};
}
private static computePublishSources(permissions: {
canSpeak: boolean;
canStream: boolean;
canVideo: boolean;
}): Array<TrackSource> {
const sources: Array<TrackSource> = [];
if (permissions.canSpeak) {
sources.push(TrackSource.MICROPHONE);
}
if (permissions.canVideo) {
sources.push(TrackSource.CAMERA);
}
if (permissions.canStream) {
sources.push(TrackSource.SCREEN_SHARE);
sources.push(TrackSource.SCREEN_SHARE_AUDIO);
}
return sources;
}
async updateParticipant(params: UpdateParticipantParams): Promise<void> {
const {userId, guildId, channelId, connectionId, regionId, serverId, mute, deaf} = params;
const roomName = this.getRoomName(guildId, channelId);
const participantIdentity = this.getParticipantIdentity(userId, connectionId);
const server = this.resolveServerClient(regionId, serverId);
try {
const participants = await server.roomServiceClient.listParticipants(roomName);
const participant = participants.find((p) => p.identity === participantIdentity);
if (!participant) {
return;
}
if (mute !== undefined && participant.tracks) {
for (const track of participant.tracks) {
if (track.type === TrackType.AUDIO && track.sid) {
await server.roomServiceClient.mutePublishedTrack(roomName, participantIdentity, track.sid, mute);
}
}
}
if (deaf !== undefined) {
await server.roomServiceClient.updateParticipant(roomName, participantIdentity, undefined, {
canPublish: !deaf,
canSubscribe: !deaf,
canPublishSources: LiveKitService.DEFAULT_PUBLISH_SOURCES,
});
}
} catch (error) {
Logger.error({error}, 'Error updating LiveKit participant');
}
}
async updateParticipantPermissions(params: UpdateParticipantPermissionsParams): Promise<void> {
const {userId, guildId, channelId, connectionId, regionId, serverId, canSpeak, canStream, canVideo} = params;
const roomName = this.getRoomName(guildId, channelId);
const participantIdentity = this.getParticipantIdentity(userId, connectionId);
const server = this.resolveServerClient(regionId, serverId);
try {
const participants = await server.roomServiceClient.listParticipants(roomName);
const participant = participants.find((p) => p.identity === participantIdentity);
if (!participant) {
Logger.warn({participantIdentity, roomName}, 'Participant not found for permission update');
return;
}
const canPublishSources = LiveKitService.computePublishSources({canSpeak, canStream, canVideo});
await server.roomServiceClient.updateParticipant(roomName, participantIdentity, undefined, {
canPublish: canPublishSources.length > 0,
canPublishSources,
});
if (!canStream && participant.tracks) {
for (const track of participant.tracks) {
if (
(track.source === TrackSource.SCREEN_SHARE || track.source === TrackSource.SCREEN_SHARE_AUDIO) &&
track.sid
) {
await server.roomServiceClient.mutePublishedTrack(roomName, participantIdentity, track.sid, true);
}
}
}
if (!canSpeak && participant.tracks) {
for (const track of participant.tracks) {
if (track.source === TrackSource.MICROPHONE && track.sid) {
await server.roomServiceClient.mutePublishedTrack(roomName, participantIdentity, track.sid, true);
}
}
}
if (!canVideo && participant.tracks) {
for (const track of participant.tracks) {
if (track.source === TrackSource.CAMERA && track.sid) {
await server.roomServiceClient.mutePublishedTrack(roomName, participantIdentity, track.sid, true);
}
}
}
Logger.info({participantIdentity, roomName, canSpeak, canStream, canVideo}, 'Updated participant permissions');
} catch (error) {
Logger.error({error}, 'Error updating LiveKit participant permissions');
}
}
async disconnectParticipant(params: DisconnectParticipantParams): Promise<void> {
const {userId, guildId, channelId, connectionId, regionId, serverId} = params;
const roomName = this.getRoomName(guildId, channelId);
const participantIdentity = this.getParticipantIdentity(userId, connectionId);
const server = this.resolveServerClient(regionId, serverId);
try {
await server.roomServiceClient.removeParticipant(roomName, participantIdentity);
} catch (error) {
if (error instanceof Error && 'status' in error && (error as {status: number}).status === 404) {
Logger.debug({participantIdentity, roomName}, 'LiveKit participant already disconnected');
return;
}
Logger.error({error}, 'Error disconnecting LiveKit participant');
}
}
async listParticipants(params: {
guildId?: GuildID;
channelId: ChannelID;
regionId: string;
serverId: string;
}): Promise<ListParticipantsResult> {
const {guildId, channelId, regionId, serverId} = params;
const roomName = this.getRoomName(guildId, channelId);
const server = this.resolveServerClient(regionId, serverId);
try {
const participants = await server.roomServiceClient.listParticipants(roomName);
return {
status: 'ok',
participants: participants.map((participant) => ({identity: participant.identity})),
};
} catch (error) {
Logger.error({error}, 'Error listing LiveKit participants');
const isRetryable =
error instanceof Error &&
'status' in error &&
((error as {status: number}).status >= 500 || (error as {status: number}).status === 404);
return {
status: 'error',
errorCode: error instanceof Error ? error.message : 'unknown',
retryable: isRetryable,
};
}
}
getDefaultRegionId(): string | null {
return this.topology.getDefaultRegionId();
}
getRegionMetadata(): Array<VoiceRegionMetadata> {
return this.topology.getRegionMetadataList();
}
getServer(regionId: string, serverId: string): VoiceServerRecord | null {
return this.topology.getServer(regionId, serverId);
}
private getRoomName(guildId: GuildID | undefined, channelId: ChannelID): string {
if (guildId === undefined) {
return `dm_channel_${channelId}`;
}
return `guild_${guildId}_channel_${channelId}`;
}
private getParticipantIdentity(userId: UserID, connectionId: string): string {
return `user_${userId}_${connectionId}`;
}
private resolveServerClient(regionId: string, serverId: string): ServerClientConfig {
const region = this.serverClients.get(regionId);
if (!region) {
throw new Error(`Unknown LiveKit region: ${regionId}`);
}
const server = region.get(serverId);
if (!server) {
throw new Error(`Unknown LiveKit server: ${regionId}/${serverId}`);
}
return server;
}
private refreshServerClients(): void {
const newMap: Map<string, Map<string, ServerClientConfig>> = new Map();
const regions = this.topology.getAllRegions();
for (const region of regions) {
const servers = this.topology.getServersForRegion(region.id);
const serverMap: Map<string, ServerClientConfig> = new Map();
for (const server of servers) {
serverMap.set(server.serverId, {
endpoint: server.endpoint,
apiKey: server.apiKey,
apiSecret: server.apiSecret,
roomServiceClient: createRoomServiceClient(server.endpoint, server.apiKey, server.apiSecret),
});
}
newMap.set(region.id, serverMap);
}
this.serverClients = newMap;
}
}

View File

@@ -0,0 +1,725 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {ILiveKitService} from '@fluxer/api/src/infrastructure/ILiveKitService';
import type {IVoiceRoomStore} from '@fluxer/api/src/infrastructure/IVoiceRoomStore';
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
import {
isDMRoom,
parseParticipantIdentity,
parseParticipantMetadataWithRaw,
parseRoomName,
} from '@fluxer/api/src/infrastructure/VoiceRoomContext';
import {Logger} from '@fluxer/api/src/Logger';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import type {VoiceTopology} from '@fluxer/api/src/voice/VoiceTopology';
import type {WebhookEvent} from 'livekit-server-sdk';
import {WebhookReceiver} from 'livekit-server-sdk';
interface VoiceWebhookParticipantContext {
readonly type: 'dm' | 'guild';
readonly channelId: ChannelID;
readonly guildId?: GuildID;
}
export class LiveKitWebhookService {
private receivers: Map<string, WebhookReceiver>;
private serverMap: Map<string, {regionId: string; serverId: string}>;
constructor(
private voiceRoomStore: IVoiceRoomStore,
private gatewayService: IGatewayService,
private userRepository: IUserRepository,
private liveKitService: ILiveKitService,
private voiceTopology: VoiceTopology,
private limitConfigService: LimitConfigService,
) {
this.receivers = new Map();
this.serverMap = new Map();
this.rebuildReceivers();
this.voiceTopology.registerSubscriber(() => this.rebuildReceivers());
}
async verifyAndParse(body: string, authHeader: string | undefined): Promise<{event: WebhookEvent; apiKey: string}> {
if (!authHeader) {
throw new Error('Missing authorization header');
}
let lastError: Error | null = null;
for (const [apiKey, receiver] of this.receivers.entries()) {
try {
const event = await receiver.receive(body, authHeader);
return {event: event as WebhookEvent, apiKey};
} catch (error) {
lastError = error as Error;
}
}
throw lastError || new Error('No webhook receivers configured');
}
async handleWebhookRequest(params: {
body: string;
authHeader: string | undefined;
}): Promise<{status: number; body: string | null}> {
const {body, authHeader} = params;
Logger.debug(
{
bodySize: body.length,
hasAuthHeader: Boolean(authHeader),
},
'Received LiveKit webhook request',
);
try {
const data = await this.verifyAndParse(body, authHeader);
const eventName = data.event.event;
Logger.debug(
{
apiKey: data.apiKey,
event: eventName,
roomName: data.event.room?.name ?? null,
participantIdentity: data.event.participant?.identity ?? null,
trackType: data.event.track?.type ?? null,
},
'Parsed LiveKit webhook event',
);
if (data.event.numDropped != null && data.event.numDropped > 0) {
Logger.warn(
{
numDropped: data.event.numDropped,
roomName: data.event.room?.name ?? null,
eventType: data.event.event,
},
'LiveKit webhook reports dropped events - reconciliation may be needed',
);
getMetricsService().counter({
name: 'fluxer.voice.webhook.events_dropped',
value: data.event.numDropped,
});
}
await this.processEvent(data);
getMetricsService().counter({
name: 'fluxer.livekit.webhooks.processed',
value: 1,
dimensions: {
event: data.event.event,
},
});
return {status: 200, body: null};
} catch (error) {
getMetricsService().counter({
name: 'fluxer.livekit.webhooks.failed',
value: 1,
dimensions: {
error_type: error instanceof Error ? error.name : 'Unknown',
},
});
Logger.debug({error}, 'Error processing LiveKit webhook');
return {status: 400, body: 'Invalid webhook'};
}
}
private rebuildReceivers(): void {
const newReceivers = new Map<string, WebhookReceiver>();
const newServerMap = new Map<string, {regionId: string; serverId: string}>();
const regions = this.voiceTopology.getAllRegions();
for (const region of regions) {
const servers = this.voiceTopology.getServersForRegion(region.id);
for (const server of servers) {
newReceivers.set(server.apiKey, new WebhookReceiver(server.apiKey, server.apiSecret));
newServerMap.set(server.apiKey, {regionId: region.id, serverId: server.serverId});
}
}
this.receivers = newReceivers;
this.serverMap = newServerMap;
Logger.debug(
{
regionCount: regions.length,
serverCount: newReceivers.size,
},
'Rebuilt LiveKit webhook receivers',
);
}
async handleRoomFinished(event: WebhookEvent, apiKey: string): Promise<void> {
if (event.event !== 'room_finished' || !event.room) {
return;
}
const roomName = event.room.name;
const context = parseRoomName(roomName);
if (!context) {
Logger.warn({roomName}, 'Unknown room name format');
return;
}
Logger.debug(
{
roomName,
contextType: context.type,
guildId: isDMRoom(context) ? undefined : context.guildId.toString(),
channelId: context.channelId.toString(),
},
'Processing LiveKit room_finished event',
);
const sourceServer = this.serverMap.get(apiKey);
if (isDMRoom(context)) {
const pinned = await this.voiceRoomStore.getPinnedRoomServer(undefined, context.channelId);
if (pinned && sourceServer && pinned.serverId !== sourceServer.serverId) {
Logger.debug(
{
channelId: context.channelId.toString(),
finishedServer: sourceServer.serverId,
pinnedServer: pinned.serverId,
},
'Ignoring room_finished from stale server — room has moved to a different server',
);
return;
}
await this.voiceRoomStore.deleteRoomServer(undefined, context.channelId);
Logger.debug({channelId: context.channelId.toString()}, 'Cleared DM voice room server pinning');
} else {
const pinned = await this.voiceRoomStore.getPinnedRoomServer(context.guildId, context.channelId);
if (pinned && sourceServer && pinned.serverId !== sourceServer.serverId) {
Logger.debug(
{
guildId: context.guildId.toString(),
channelId: context.channelId.toString(),
finishedServer: sourceServer.serverId,
pinnedServer: pinned.serverId,
},
'Ignoring room_finished from stale server — room has moved to a different server',
);
return;
}
await this.voiceRoomStore.deleteRoomServer(context.guildId, context.channelId);
Logger.debug(
{guildId: context.guildId.toString(), channelId: context.channelId.toString()},
'Cleared guild voice room server pinning',
);
try {
const result = await this.gatewayService.disconnectAllVoiceUsersInChannel({
guildId: context.guildId,
channelId: context.channelId,
});
Logger.info(
{
guildId: context.guildId.toString(),
channelId: context.channelId.toString(),
disconnectedCount: result.disconnectedCount,
},
'Cleaned up zombie voice connections for finished room',
);
} catch (error) {
Logger.error(
{error, guildId: context.guildId.toString(), channelId: context.channelId.toString()},
'Failed to clean up voice connections for finished room',
);
}
}
}
async handleParticipantJoined(event: WebhookEvent): Promise<void> {
if (event.event !== 'participant_joined') {
return;
}
const {participant} = event;
if (!participant?.metadata) {
Logger.debug('Participant joined without metadata, skipping');
return;
}
const parsed = parseParticipantMetadataWithRaw(participant.metadata);
if (!parsed) {
Logger.warn({metadata: participant.metadata}, 'Failed to parse participant metadata');
return;
}
const {context, raw} = parsed;
const tokenNonce = raw.token_nonce;
Logger.debug(
{
type: context.type,
participantIdentity: participant.identity,
roomName: event.room?.name ?? null,
channelId: context.channelId.toString(),
guildId: context.type === 'guild' ? context.guildId.toString() : undefined,
connectionId: context.connectionId,
tokenNonce,
},
'Processing LiveKit participant_joined event',
);
try {
const guildId = context.type === 'guild' ? context.guildId : undefined;
Logger.info(
{
type: context.type,
guildId: guildId?.toString(),
channelId: context.channelId.toString(),
connectionId: context.connectionId,
participantIdentity: participant.identity,
},
'LiveKit participant_joined - confirming voice connection',
);
const result = await this.gatewayService.confirmVoiceConnection({
guildId,
channelId: context.channelId,
connectionId: context.connectionId,
tokenNonce,
});
Logger.debug(
{
type: context.type,
guildId: guildId?.toString(),
channelId: context.channelId.toString(),
connectionId: context.connectionId,
success: result.success,
error: result.error,
},
'LiveKit voice connection confirm result',
);
if (!result.success) {
Logger.warn(
{
type: context.type,
guildId: guildId?.toString(),
channelId: context.channelId.toString(),
connectionId: context.connectionId,
error: result.error,
participantIdentity: participant.identity,
},
'LiveKit participant_joined rejected - disconnecting participant',
);
getMetricsService().counter({
name: 'fluxer.voice.webhook.join_rejected',
value: 1,
dimensions: {reason: result.error ?? 'unknown'},
});
try {
await this.liveKitService.disconnectParticipant({
guildId,
channelId: context.channelId,
userId: context.userId,
connectionId: context.connectionId,
regionId: raw.region_id ?? '',
serverId: raw.server_id ?? '',
});
} catch (disconnectError) {
Logger.error({error: disconnectError}, 'Failed to disconnect rejected participant');
}
return;
}
getMetricsService().counter({
name: 'fluxer.voice.webhook.join_confirmed',
value: 1,
});
} catch (error) {
Logger.error({error, type: context.type}, 'Error processing participant_joined');
}
}
private async isParticipantStillInRoom(params: {
participantIdentity: string;
context: VoiceWebhookParticipantContext;
regionId?: string;
serverId?: string;
}): Promise<'present' | 'absent' | 'unknown'> {
const {participantIdentity, context} = params;
const guildId = context.type === 'guild' ? context.guildId : undefined;
let regionId = params.regionId;
let serverId = params.serverId;
if (!regionId || !serverId) {
const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(guildId, context.channelId);
if (pinnedServer) {
regionId = pinnedServer.regionId;
serverId = pinnedServer.serverId;
}
}
if (!regionId || !serverId) {
return 'unknown';
}
const result = await this.liveKitService.listParticipants({
guildId,
channelId: context.channelId,
regionId,
serverId,
});
if (result.status === 'error') {
Logger.warn(
{errorCode: result.errorCode, retryable: result.retryable, participantIdentity},
'Cannot determine participant presence due to LiveKit lookup failure',
);
getMetricsService().counter({
name: 'fluxer.voice.reconcile.lookup_error',
value: 1,
});
return 'unknown';
}
return result.participants.some((p) => p.identity === participantIdentity) ? 'present' : 'absent';
}
async handleParticipantLeft(event: WebhookEvent): Promise<void> {
if (event.event !== 'participant_left' && event.event !== 'participant_connection_aborted') {
return;
}
const {participant} = event;
if (!participant?.metadata) {
Logger.debug('Participant left without metadata, skipping');
return;
}
const parsed = parseParticipantMetadataWithRaw(participant.metadata);
if (!parsed) {
Logger.warn({metadata: participant.metadata}, 'Failed to parse participant metadata');
return;
}
const {context, raw} = parsed;
Logger.debug(
{
type: context.type,
participantIdentity: participant.identity,
roomName: event.room?.name ?? null,
channelId: context.channelId.toString(),
guildId: context.type === 'guild' ? context.guildId.toString() : undefined,
userId: context.userId.toString(),
connectionId: context.connectionId,
},
`Processing LiveKit ${event.event} event`,
);
try {
if (raw.region_id && raw.server_id) {
const guildId = context.type === 'guild' ? context.guildId : undefined;
const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(guildId, context.channelId);
if (pinnedServer && (pinnedServer.regionId !== raw.region_id || pinnedServer.serverId !== raw.server_id)) {
Logger.info(
{
type: context.type,
participantIdentity: participant.identity,
channelId: context.channelId.toString(),
guildId: context.type === 'guild' ? context.guildId.toString() : undefined,
connectionId: context.connectionId,
eventRegionId: raw.region_id,
eventServerId: raw.server_id,
currentRegionId: pinnedServer.regionId,
currentServerId: pinnedServer.serverId,
},
'Ignoring participant_left from stale server - room has migrated to a different server',
);
getMetricsService().counter({
name: 'fluxer.voice.webhook.stale_server_event_ignored',
value: 1,
});
return;
}
}
const presenceStatus = await this.isParticipantStillInRoom({
participantIdentity: participant.identity,
context,
regionId: raw.region_id,
serverId: raw.server_id,
});
if (presenceStatus === 'present') {
Logger.warn(
{
type: context.type,
participantIdentity: participant.identity,
channelId: context.channelId.toString(),
guildId: context.type === 'guild' ? context.guildId.toString() : undefined,
connectionId: context.connectionId,
},
'Ignoring stale participant_left event because participant is still present in room',
);
return;
}
if (presenceStatus === 'unknown') {
Logger.warn(
{
type: context.type,
participantIdentity: participant.identity,
channelId: context.channelId.toString(),
guildId: context.type === 'guild' ? context.guildId.toString() : undefined,
connectionId: context.connectionId,
},
'Skipping participant_left disconnect because participant presence is uncertain',
);
getMetricsService().counter({
name: 'fluxer.voice.reconcile.disconnect_ignored',
value: 1,
});
return;
}
const guildId = context.type === 'guild' ? context.guildId : undefined;
Logger.info(
{
type: context.type,
guildId: guildId?.toString(),
userId: context.userId.toString(),
channelId: context.channelId.toString(),
connectionId: context.connectionId,
},
'LiveKit participant_left - disconnecting voice user',
);
const result = await this.gatewayService.disconnectVoiceUserIfInChannel({
guildId,
channelId: context.channelId,
userId: context.userId,
connectionId: context.connectionId,
});
Logger.debug(
{
type: context.type,
guildId: guildId?.toString(),
userId: context.userId.toString(),
channelId: context.channelId.toString(),
connectionId: context.connectionId,
result,
},
'LiveKit participant_left voice disconnect result',
);
} catch (error) {
Logger.error({error, type: context.type}, 'Error processing participant_left');
}
}
async handleTrackPublished(event: WebhookEvent, apiKey: string): Promise<void> {
if (event.event !== 'track_published') {
return;
}
const {room, participant, track} = event;
if (!room || !participant || !track) {
Logger.debug('Track published without required data, skipping');
return;
}
Logger.debug(
{
apiKey,
roomName: room.name,
participantIdentity: participant.identity,
trackType: track.type,
width: track.width,
height: track.height,
},
'Processing LiveKit track_published event',
);
if (track.type !== 1) {
return;
}
try {
const identity = parseParticipantIdentity(participant.identity);
if (!identity) {
Logger.warn({identity: participant.identity}, 'Unexpected participant identity format');
return;
}
const {userId, connectionId} = identity;
const user = await this.userRepository.findUnique(userId);
if (!user) {
Logger.warn({userId: userId.toString()}, 'User not found for track_published event');
return;
}
if (Config.instance.selfHosted) {
return;
}
const ctx = createLimitMatchContext({user});
const hasHigherQuality = resolveLimitSafe(
this.limitConfigService.getConfigSnapshot(),
ctx,
'feature_higher_video_quality',
0,
);
if (hasHigherQuality > 0) {
return;
}
const FREE_MAX_WIDTH = 1280;
const FREE_MAX_HEIGHT = 720;
const exceedsResolution = track.width > FREE_MAX_WIDTH || track.height > FREE_MAX_HEIGHT;
if (!exceedsResolution) {
return;
}
Logger.warn(
{userId: userId.toString(), width: track.width, height: track.height},
'Non-premium user attempting to publish video exceeding free tier limits - disconnecting',
);
const roomContext = parseRoomName(room.name);
if (!roomContext) {
Logger.warn({roomName: room.name}, 'Unknown room name format, cannot disconnect');
return;
}
let regionId: string | undefined;
let serverId: string | undefined;
if (participant.metadata) {
const parsed = parseParticipantMetadataWithRaw(participant.metadata);
if (parsed) {
regionId = parsed.raw.region_id;
serverId = parsed.raw.server_id;
}
}
if (!regionId || !serverId) {
const serverInfo = this.serverMap.get(apiKey);
if (serverInfo) {
regionId = serverInfo.regionId;
serverId = serverInfo.serverId;
}
}
if (!regionId || !serverId) {
const guildId = isDMRoom(roomContext) ? undefined : roomContext.guildId;
const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(guildId, roomContext.channelId);
if (pinnedServer) {
regionId = pinnedServer.regionId;
serverId = pinnedServer.serverId;
}
}
if (!regionId || !serverId) {
Logger.warn(
{participantId: participant.identity, roomName: room.name, apiKey},
'Missing region or server info, cannot disconnect',
);
return;
}
const guildId = isDMRoom(roomContext) ? undefined : roomContext.guildId;
Logger.info(
{
userId: userId.toString(),
type: roomContext.type,
guildId: guildId?.toString(),
channelId: roomContext.channelId.toString(),
regionId,
serverId,
width: track.width,
height: track.height,
},
'Disconnecting non-premium user for exceeding video quality limits',
);
await this.liveKitService.disconnectParticipant({
userId,
guildId,
channelId: roomContext.channelId,
connectionId,
regionId,
serverId,
});
await this.gatewayService.disconnectVoiceUserIfInChannel({
guildId,
channelId: roomContext.channelId,
userId,
connectionId,
});
Logger.info(
{
userId: userId.toString(),
type: roomContext.type,
guildId: guildId?.toString(),
channelId: roomContext.channelId.toString(),
width: track.width,
height: track.height,
},
'Disconnected non-premium user for exceeding video quality limits',
);
} catch (error) {
Logger.error({error}, 'Error processing track_published event');
}
}
async processEvent(data: {event: WebhookEvent; apiKey: string}): Promise<void> {
const {event, apiKey} = data;
Logger.debug({event: event.event, apiKey}, 'Dispatching LiveKit webhook event');
switch (event.event) {
case 'participant_joined':
await this.handleParticipantJoined(event);
break;
case 'participant_left':
case 'participant_connection_aborted':
await this.handleParticipantLeft(event);
break;
case 'room_finished':
await this.handleRoomFinished(event, apiKey);
break;
case 'track_published':
await this.handleTrackPublished(event, apiKey);
break;
default:
Logger.debug({event: event.event}, 'Ignoring LiveKit webhook event');
}
Logger.debug({event: event.event, apiKey}, 'Finished LiveKit webhook event');
}
}

View File

@@ -0,0 +1,169 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import {
IMediaService,
type MediaProxyFrameRequest,
type MediaProxyFrameResponse,
type MediaProxyMetadataRequest,
type MediaProxyMetadataResponse,
} from '@fluxer/api/src/infrastructure/IMediaService';
import {Logger} from '@fluxer/api/src/Logger';
import {ExplicitContentCannotBeSentError} from '@fluxer/errors/src/domains/moderation/ExplicitContentCannotBeSentError';
import * as MediaProxyUtils from '@fluxer/media_proxy_utils/src/MediaProxyUtils';
type MediaProxyRequestBody =
| MediaProxyMetadataRequest
| MediaProxyFrameRequest
| {type: 'upload'; upload_filename: string};
export class MediaService extends IMediaService {
private readonly proxyURL: URL;
constructor() {
super();
this.proxyURL = new URL(Config.endpoints.media);
}
async getMetadata(request: MediaProxyMetadataRequest): Promise<MediaProxyMetadataResponse | null> {
const response = await this.makeRequest('/_metadata', request);
if (!response) {
return null;
}
try {
const responseText = await response.text();
if (!responseText) {
Logger.error('Media proxy returned empty response');
return null;
}
const metadata = JSON.parse(responseText) as MediaProxyMetadataResponse;
if (!request.isNSFWAllowed && metadata.nsfw) {
throw new ExplicitContentCannotBeSentError(metadata.nsfw_probability ?? 0, metadata.nsfw_predictions ?? {});
}
return {
...metadata,
format: metadata.format.toLowerCase(),
};
} catch (error) {
if (error instanceof ExplicitContentCannotBeSentError) {
throw error;
}
Logger.error({error}, 'Failed to parse media proxy metadata response');
return null;
}
}
getExternalMediaProxyURL(url: string): string {
let urlObj: URL;
try {
urlObj = new URL(url);
} catch (_e) {
return this.handleExternalURL(url);
}
if (urlObj.host === this.proxyURL.host) {
return url;
}
return this.handleExternalURL(url);
}
async getThumbnail(uploadFilename: string): Promise<Buffer | null> {
const response = await this.makeRequest('/_thumbnail', {
type: 'upload',
upload_filename: uploadFilename,
});
if (!response) return null;
try {
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
Logger.error({error, uploadFilename}, 'Failed to parse media proxy thumbnail response');
return null;
}
}
async extractFrames(request: MediaProxyFrameRequest): Promise<MediaProxyFrameResponse> {
const response = await this.makeRequest('/_frames', request);
if (!response) {
throw new Error('Unable to extract frames: no response from media proxy');
}
const data = (await response.json()) as MediaProxyFrameResponse;
return data;
}
private async makeRequest(endpoint: string, body: MediaProxyRequestBody): Promise<Response | null> {
try {
const url = `http://${Config.mediaProxy.host}:${Config.mediaProxy.port}${endpoint}`;
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${Config.mediaProxy.secretKey}`,
},
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Could not read error body');
Logger.error(
{
status: response.status,
statusText: response.statusText,
errorBody: errorText,
body: this.sanitizeRequestBody(body),
endpoint: url,
},
'Media proxy request failed',
);
return null;
}
return response;
} catch (error) {
Logger.error({error, endpoint}, 'Failed to make media proxy request');
return null;
}
}
private sanitizeRequestBody(body: MediaProxyRequestBody): MediaProxyRequestBody {
if (body?.type === 'base64') {
return {
...body,
base64: '[BASE64_DATA_OMITTED]',
};
}
return body;
}
private handleExternalURL(url: string): string {
return MediaProxyUtils.getExternalMediaProxyURL({
inputURL: url,
mediaProxyEndpoint: Config.endpoints.media,
mediaProxySecretKey: Config.mediaProxy.secretKey,
});
}
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ILogger} from '@fluxer/api/src/ILogger';
import type {IAuditLogSearchService} from '@fluxer/api/src/search/IAuditLogSearchService';
import type {IGuildMemberSearchService} from '@fluxer/api/src/search/IGuildMemberSearchService';
import type {IGuildSearchService} from '@fluxer/api/src/search/IGuildSearchService';
import type {IMessageSearchService} from '@fluxer/api/src/search/IMessageSearchService';
import type {IReportSearchService} from '@fluxer/api/src/search/IReportSearchService';
import type {ISearchProvider} from '@fluxer/api/src/search/ISearchProvider';
import type {IUserSearchService} from '@fluxer/api/src/search/IUserSearchService';
import {MeilisearchAuditLogSearchService} from '@fluxer/api/src/search/meilisearch/MeilisearchAuditLogSearchService';
import {MeilisearchGuildMemberSearchService} from '@fluxer/api/src/search/meilisearch/MeilisearchGuildMemberSearchService';
import {MeilisearchGuildSearchService} from '@fluxer/api/src/search/meilisearch/MeilisearchGuildSearchService';
import {MeilisearchMessageSearchService} from '@fluxer/api/src/search/meilisearch/MeilisearchMessageSearchService';
import {MeilisearchReportSearchService} from '@fluxer/api/src/search/meilisearch/MeilisearchReportSearchService';
import {MeilisearchUserSearchService} from '@fluxer/api/src/search/meilisearch/MeilisearchUserSearchService';
import {createMeilisearchClient} from '@fluxer/meilisearch_search/src/MeilisearchClient';
export interface MeilisearchSearchProviderConfig {
url: string;
apiKey: string;
timeoutMs: number;
taskWaitTimeoutMs: number;
taskPollIntervalMs: number;
}
export interface MeilisearchSearchProviderOptions {
config: MeilisearchSearchProviderConfig;
logger: ILogger;
}
export class MeilisearchSearchProvider implements ISearchProvider {
private readonly logger: ILogger;
private readonly config: MeilisearchSearchProviderConfig;
private messageService: MeilisearchMessageSearchService | null = null;
private guildService: MeilisearchGuildSearchService | null = null;
private userService: MeilisearchUserSearchService | null = null;
private reportService: MeilisearchReportSearchService | null = null;
private auditLogService: MeilisearchAuditLogSearchService | null = null;
private guildMemberService: MeilisearchGuildMemberSearchService | null = null;
constructor(options: MeilisearchSearchProviderOptions) {
this.logger = options.logger;
this.config = options.config;
}
async initialize(): Promise<void> {
const client = createMeilisearchClient({
url: this.config.url,
apiKey: this.config.apiKey,
timeoutMs: this.config.timeoutMs,
});
const waitForTasks = {
enabled: true,
timeoutMs: this.config.taskWaitTimeoutMs,
intervalMs: this.config.taskPollIntervalMs,
};
this.messageService = new MeilisearchMessageSearchService({client, waitForTasks});
this.guildService = new MeilisearchGuildSearchService({client, waitForTasks});
this.userService = new MeilisearchUserSearchService({client, waitForTasks});
this.reportService = new MeilisearchReportSearchService({client, waitForTasks});
this.auditLogService = new MeilisearchAuditLogSearchService({client, waitForTasks});
this.guildMemberService = new MeilisearchGuildMemberSearchService({client, waitForTasks});
await Promise.all([
this.messageService.initialize(),
this.guildService.initialize(),
this.userService.initialize(),
this.reportService.initialize(),
this.auditLogService.initialize(),
this.guildMemberService.initialize(),
]);
this.logger.info({url: this.config.url}, 'MeilisearchSearchProvider initialised');
}
async shutdown(): Promise<void> {
const services = [
this.messageService,
this.guildService,
this.userService,
this.reportService,
this.auditLogService,
this.guildMemberService,
];
await Promise.all(services.filter((s) => s != null).map((s) => s.shutdown()));
this.messageService = null;
this.guildService = null;
this.userService = null;
this.reportService = null;
this.auditLogService = null;
this.guildMemberService = null;
}
getMessageSearchService(): IMessageSearchService | null {
return this.messageService;
}
getGuildSearchService(): IGuildSearchService | null {
return this.guildService;
}
getUserSearchService(): IUserSearchService | null {
return this.userService;
}
getReportSearchService(): IReportSearchService | null {
return this.reportService;
}
getAuditLogSearchService(): IAuditLogSearchService | null {
return this.auditLogService;
}
getGuildMemberSearchService(): IGuildMemberSearchService | null {
return this.guildMemberService;
}
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {
BatchMetric,
CounterParams,
CrashParams,
GaugeParams,
HistogramParams,
IMetricsService,
} from '@fluxer/api/src/infrastructure/IMetricsService';
import {isTelemetryEnabled, recordCounter, recordGauge, recordHistogram} from '@fluxer/api/src/Telemetry';
class MetricsService implements IMetricsService {
counter(params: CounterParams): void {
recordCounter({
name: params.name,
value: params.value,
dimensions: params.dimensions,
});
}
gauge(params: GaugeParams): void {
recordGauge({
name: params.name,
value: params.value,
dimensions: params.dimensions,
});
}
histogram(params: HistogramParams): void {
recordHistogram({
name: params.name,
valueMs: params.valueMs,
dimensions: params.dimensions,
});
}
crash({guildId}: CrashParams): void {
recordCounter({
name: 'app.crash',
dimensions: {guild_id: guildId ?? 'unknown'},
value: 1,
});
}
batch(metrics: Array<BatchMetric>): void {
for (const metric of metrics) {
switch (metric.type) {
case 'counter':
recordCounter({
name: metric.name,
value: metric.value ?? 1,
dimensions: metric.dimensions,
});
break;
case 'gauge':
recordGauge({
name: metric.name,
value: metric.value ?? 0,
dimensions: metric.dimensions,
});
break;
case 'histogram':
if (metric.valueMs !== undefined) {
recordHistogram({
name: metric.name,
valueMs: metric.valueMs,
dimensions: metric.dimensions,
});
}
break;
}
}
}
isEnabled(): boolean {
return isTelemetryEnabled();
}
}
let metricsServiceInstance: IMetricsService | null = null;
export function initializeMetricsService(): IMetricsService {
if (!metricsServiceInstance) {
metricsServiceInstance = new MetricsService();
}
return metricsServiceInstance;
}
export function getMetricsService(): IMetricsService {
return initializeMetricsService();
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IAuditLogSearchService} from '@fluxer/api/src/search/IAuditLogSearchService';
import type {IGuildMemberSearchService} from '@fluxer/api/src/search/IGuildMemberSearchService';
import type {IGuildSearchService} from '@fluxer/api/src/search/IGuildSearchService';
import type {IMessageSearchService} from '@fluxer/api/src/search/IMessageSearchService';
import type {IReportSearchService} from '@fluxer/api/src/search/IReportSearchService';
import type {ISearchProvider} from '@fluxer/api/src/search/ISearchProvider';
import type {IUserSearchService} from '@fluxer/api/src/search/IUserSearchService';
export class NullSearchProvider implements ISearchProvider {
async initialize(): Promise<void> {}
async shutdown(): Promise<void> {}
getMessageSearchService(): IMessageSearchService | null {
return null;
}
getGuildSearchService(): IGuildSearchService | null {
return null;
}
getUserSearchService(): IUserSearchService | null {
return null;
}
getReportSearchService(): IReportSearchService | null {
return null;
}
getAuditLogSearchService(): IAuditLogSearchService | null {
return null;
}
getGuildMemberSearchService(): IGuildMemberSearchService | null {
return null;
}
}

View File

@@ -0,0 +1,262 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomUUID} from 'node:crypto';
import type {ISnowflakeService} from '@fluxer/api/src/infrastructure/ISnowflakeService';
import {Logger} from '@fluxer/api/src/Logger';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
import {
FLUXER_EPOCH,
MAX_SEQUENCE,
MAX_WORKER_ID,
SEQUENCE_BITS,
TIMESTAMP_SHIFT,
} from '@fluxer/snowflake/src/Snowflake';
import {seconds} from 'itty-time';
const MAX_SEQ = Number(MAX_SEQUENCE);
const MAX_NODE_ID = Number(MAX_WORKER_ID);
const NODE_ID_TTL = seconds('5 minutes');
const NODE_ID_RENEWAL_INTERVAL = seconds('4 minutes');
export class SnowflakeService implements ISnowflakeService {
private epoch: bigint;
private nodeId: number | null = null;
private seq: number;
private lastSeqExhaustion: bigint;
private kvClient: IKVProvider | null = null;
private instanceId: string;
private renewalInterval: NodeJS.Timeout | null = null;
private initializationPromise: Promise<void> | null = null;
private abortRenewalLoop: boolean = false;
private shutdownRequested: boolean = false;
constructor(kvClient?: IKVProvider) {
this.epoch = BigInt(FLUXER_EPOCH);
this.seq = 0;
this.lastSeqExhaustion = 0n;
this.instanceId = randomUUID();
if (kvClient) {
this.kvClient = kvClient;
}
}
async initialize(): Promise<void> {
if (this.nodeId != null || this.shutdownRequested) {
return;
}
if (!this.initializationPromise) {
this.initializationPromise = (async () => {
this.abortRenewalLoop = false;
if (this.kvClient) {
this.nodeId = await this.acquireNodeId();
this.startNodeIdRenewal();
} else {
this.nodeId = 0;
}
})().finally(() => {
this.initializationPromise = null;
});
}
await this.initializationPromise;
}
private async acquireNodeId(): Promise<number> {
if (!this.kvClient) {
throw new Error('KV client not available for node ID allocation');
}
const nodeIdKey = 'snowflake:node_counter';
const nodeRegistryKey = 'snowflake:nodes';
for (let attempt = 0; attempt < MAX_NODE_ID; attempt++) {
const candidateId = await this.kvClient.incr(nodeIdKey);
const normalizedId = (candidateId - 1) % (MAX_NODE_ID + 1);
const lockKey = `snowflake:node:${normalizedId}`;
const acquired = await this.kvClient.set(lockKey, this.instanceId, 'EX', NODE_ID_TTL, 'NX');
if (acquired === 'OK') {
await this.kvClient.hset(nodeRegistryKey, normalizedId.toString(), this.instanceId);
return normalizedId;
}
}
throw new Error('Unable to acquire unique node ID - all nodes in use');
}
private startNodeIdRenewal(): void {
if (this.renewalInterval) {
return;
}
this.abortRenewalLoop = false;
const runRenewalLoop = async () => {
while (this.renewalInterval && !this.abortRenewalLoop) {
await this.sleep(NODE_ID_RENEWAL_INTERVAL * 1000);
if (this.renewalInterval && !this.abortRenewalLoop) {
try {
await this.renewNodeId();
} catch (error) {
Logger.error(
{
error: error instanceof Error ? error.message : String(error),
nodeId: this.nodeId,
},
'Failed to renew snowflake node ID lock',
);
this.handleLostNodeId();
}
}
}
};
this.renewalInterval = setTimeout(() => {}, 0);
runRenewalLoop();
}
private async renewNodeId(): Promise<void> {
if (!this.kvClient) {
throw new Error('SnowflakeService: Cannot renew node ID - kvClient not initialized');
}
if (this.nodeId == null) {
throw new Error('SnowflakeService: Cannot renew node ID - nodeId is null');
}
const lockKey = `snowflake:node:${this.nodeId}`;
const renewed = await this.renewNodeLockIfOwned(lockKey);
if (!renewed) {
Logger.warn({nodeId: this.nodeId, lockKey}, 'Lost ownership of snowflake node ID lock');
this.handleLostNodeId();
}
}
private async renewNodeLockIfOwned(lockKey: string): Promise<boolean> {
if (!this.kvClient) {
throw new Error('SnowflakeService: Cannot renew node lock - kvClient not initialized');
}
return await this.kvClient.renewSnowflakeNode(lockKey, this.instanceId, NODE_ID_TTL);
}
private handleLostNodeId(): void {
this.nodeId = null;
this.abortRenewalLoop = true;
if (this.renewalInterval) {
clearTimeout(this.renewalInterval);
this.renewalInterval = null;
}
this.abortRenewalLoop = false;
}
async reinitialize(): Promise<void> {
this.shutdownRequested = false;
this.abortRenewalLoop = false;
this.nodeId = null;
this.initializationPromise = null;
await this.initialize();
}
async shutdown(): Promise<void> {
this.shutdownRequested = true;
this.abortRenewalLoop = true;
if (this.renewalInterval) {
clearTimeout(this.renewalInterval);
this.renewalInterval = null;
}
const nodeId = this.nodeId;
if (this.kvClient && nodeId != null) {
const lockKey = `snowflake:node:${nodeId}`;
const nodeRegistryKey = 'snowflake:nodes';
this.nodeId = null;
try {
await this.kvClient.del(lockKey);
await this.kvClient.hdel(nodeRegistryKey, nodeId.toString());
} catch (err) {
Logger.error(
{
error: err instanceof Error ? err.message : String(err),
nodeId,
lockKey,
},
'Failed to release node ID during shutdown',
);
}
} else {
this.nodeId = null;
}
}
public async generate(): Promise<bigint> {
if (this.nodeId == null) {
throw new Error('SnowflakeService not initialized - call initialize() first');
}
const currentTime = BigInt(Date.now());
return this.generateWithTimestamp(currentTime);
}
private async generateWithTimestamp(timestamp: bigint): Promise<bigint> {
if (this.nodeId == null) {
throw new Error('SnowflakeService not initialized - call initialize() first');
}
while (this.seq === 0 && timestamp <= this.lastSeqExhaustion) {
await this.sleep(1);
timestamp = BigInt(Date.now());
}
const epochDiff = timestamp - this.epoch;
const snowflakeId = (epochDiff << TIMESTAMP_SHIFT) | (BigInt(this.nodeId) << SEQUENCE_BITS) | BigInt(this.seq);
if (this.seq >= MAX_SEQ) {
this.seq = 0;
this.lastSeqExhaustion = timestamp;
} else {
this.seq += 1;
}
return snowflakeId;
}
private async sleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
public getNodeIdForTesting(): number | null {
return this.nodeId;
}
public async renewNodeIdForTesting(): Promise<void> {
await this.renewNodeId();
}
}

View File

@@ -0,0 +1,163 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {execFile} from 'node:child_process';
import fs from 'node:fs';
import type {Readable as NodeReadable} from 'node:stream';
import {promisify} from 'node:util';
import {Logger} from '@fluxer/api/src/Logger';
import sharp from 'sharp';
import {temporaryFile} from 'tempy';
const execFilePromise = promisify(execFile);
export interface JpegUploadTarget {
bucket: string;
key: string;
}
export interface JpegUploadRequest {
sourceData: Uint8Array;
contentType: string;
destination: JpegUploadTarget;
uploadObject: (params: {bucket: string; key: string; body: Uint8Array; contentType?: string}) => Promise<void>;
}
export async function streamToBuffer(stream: NodeReadable, maxBytes = 50 * 1024 * 1024): Promise<Uint8Array> {
const chunks: Array<Uint8Array> = [];
let totalSize = 0;
try {
for await (const chunk of stream) {
const chunkBuffer = new Uint8Array(Buffer.from(chunk));
totalSize += chunkBuffer.length;
if (totalSize > maxBytes) {
stream.destroy();
throw new Error(`Stream exceeds maximum buffer size of ${maxBytes} bytes (got ${totalSize} bytes)`);
}
chunks.push(chunkBuffer);
}
return new Uint8Array(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))));
} catch (error) {
if (!stream.destroyed) {
stream.destroy();
}
throw error;
}
}
export async function processAndUploadJpeg(params: JpegUploadRequest): Promise<{width: number; height: number} | null> {
const inputPath = temporaryFile({extension: 'jpg'});
const outputPath = temporaryFile({extension: 'jpg'});
try {
await fs.promises.writeFile(inputPath, params.sourceData);
const orientation = await getJpegOrientation(inputPath);
const image = sharp(params.sourceData);
const metadata = await image.metadata();
const processedBuffer = await image
.rotate(orientation === 6 ? 90 : 0)
.jpeg({
quality: 100,
chromaSubsampling: '4:2:0',
})
.toBuffer();
await fs.promises.writeFile(outputPath, processedBuffer);
await stripJpegMetadata(outputPath);
const finalBuffer = await fs.promises.readFile(outputPath);
await params.uploadObject({
bucket: params.destination.bucket,
key: params.destination.key,
body: finalBuffer,
contentType: params.contentType,
});
const cleanupErrors = await cleanupTempFiles([inputPath, outputPath]);
if (cleanupErrors.length > 0) {
throw new Error(
`Failed to cleanup temporary files: ${cleanupErrors.map((e) => e.path).join(', ')}. This may indicate disk space or permission issues.`,
);
}
if (metadata.width && metadata.height) {
return orientation === 6
? {width: metadata.height, height: metadata.width}
: {width: metadata.width, height: metadata.height};
}
return null;
} catch (error) {
const cleanupErrors = await cleanupTempFiles([inputPath, outputPath]);
if (cleanupErrors.length > 0) {
Logger.error({cleanupErrors, originalError: error}, 'Failed to cleanup temp files after operation failure');
}
throw error;
}
}
async function cleanupTempFiles(paths: ReadonlyArray<string>): Promise<Array<{path: string; error: unknown}>> {
const cleanupErrors: Array<{path: string; error: unknown}> = [];
await Promise.all(
paths.map((filePath) =>
fs.promises.unlink(filePath).catch((error) => {
cleanupErrors.push({path: filePath, error});
}),
),
);
return cleanupErrors;
}
async function getJpegOrientation(filePath: string): Promise<number> {
const {stdout} = await execFilePromise('exiftool', ['-Orientation#', '-n', '-j', filePath]);
try {
const [{Orientation = 1}] = JSON.parse(stdout);
return Orientation;
} catch (error) {
Logger.error({error, filePath, stdout}, 'Failed to parse exiftool JSON output');
return 1;
}
}
async function stripJpegMetadata(filePath: string): Promise<void> {
await execFilePromise('exiftool', [
'-all=',
'-jfif:all=',
'-JFIFVersion=1.01',
'-ResolutionUnit=none',
'-XResolution=1',
'-YResolution=1',
'-n',
'-overwrite_original',
'-F',
'-exif:all=',
'-iptc:all=',
'-xmp:all=',
'-icc_profile:all=',
'-photoshop:all=',
'-adobe:all=',
filePath,
]);
}

View File

@@ -0,0 +1,368 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {PassThrough, pipeline, Readable} from 'node:stream';
import {promisify} from 'node:util';
import {
CopyObjectCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
type GetObjectCommandOutput,
HeadObjectCommand,
type HeadObjectCommandOutput,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
S3ServiceException,
} from '@aws-sdk/client-s3';
import {getSignedUrl} from '@aws-sdk/s3-request-presigner';
import {Config} from '@fluxer/api/src/Config';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import {processAndUploadJpeg, streamToBuffer} from '@fluxer/api/src/infrastructure/StorageObjectHelpers';
import {Logger} from '@fluxer/api/src/Logger';
import {seconds} from 'itty-time';
const pipelinePromise = promisify(pipeline);
export class StorageService implements IStorageService {
private readonly s3: S3Client;
private readonly presignClient: S3Client;
constructor() {
const baseInit = {
endpoint: Config.s3.endpoint,
region: Config.s3.region,
credentials: {
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
},
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
} as const;
this.s3 = new S3Client({...baseInit, forcePathStyle: true});
this.presignClient = new S3Client({...baseInit, forcePathStyle: false});
}
private getClient(_bucket: string): S3Client {
return this.s3;
}
async uploadObject({
bucket,
key,
body,
contentType,
expiresAt,
}: {
bucket: string;
key: string;
body: Uint8Array;
contentType?: string;
expiresAt?: Date;
}): Promise<void> {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: contentType,
Expires: expiresAt,
});
await this.getClient(bucket).send(command);
}
async getPresignedDownloadURL({
bucket,
key,
expiresIn = seconds('5 minutes'),
}: {
bucket: string;
key: string;
expiresIn?: number;
}): Promise<string> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
return getSignedUrl(this.presignClient, command, {expiresIn});
}
async deleteObject(bucket: string, key: string): Promise<void> {
const command = new DeleteObjectCommand({Bucket: bucket, Key: key});
await this.getClient(bucket).send(command);
}
async getObjectMetadata(bucket: string, key: string): Promise<{contentLength: number; contentType: string} | null> {
try {
const command = new HeadObjectCommand({Bucket: bucket, Key: key});
const response = await this.getClient(bucket).send(command);
return {
contentLength: response.ContentLength ?? 0,
contentType: response.ContentType ?? '',
};
} catch (error) {
if (error instanceof S3ServiceException && error.name === 'NotFound') {
return null;
}
throw error;
}
}
async readObject(bucket: string, key: string): Promise<Uint8Array> {
const command = new GetObjectCommand({Bucket: bucket, Key: key});
const {Body} = await this.getClient(bucket).send(command);
assert(Body != null && Body instanceof Readable);
const stream = Body instanceof PassThrough ? Body : Body.pipe(new PassThrough());
return streamToBuffer(stream);
}
async streamObject(params: {bucket: string; key: string; range?: string}): Promise<{
body: Readable;
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null> {
const command = new GetObjectCommand({
Bucket: params.bucket,
Key: params.key,
Range: params.range,
});
const response = await this.getClient(params.bucket).send(command);
assert(response.Body != null && response.Body instanceof Readable);
const stream = response.Body instanceof PassThrough ? response.Body : response.Body.pipe(new PassThrough());
return {
body: stream,
contentLength: response.ContentLength ?? 0,
contentRange: response.ContentRange ?? null,
contentType: response.ContentType ?? null,
cacheControl: response.CacheControl ?? null,
contentDisposition: response.ContentDisposition ?? null,
expires: response.Expires ?? null,
etag: response.ETag ?? null,
lastModified: response.LastModified ?? null,
};
}
private async ensureDirectoryExists(dirPath: string): Promise<void> {
await fs.promises.mkdir(dirPath, {recursive: true});
}
async writeObjectToDisk(bucket: string, key: string, filePath: string): Promise<void> {
await this.ensureDirectoryExists(path.dirname(filePath));
const command = new GetObjectCommand({Bucket: bucket, Key: key});
const {Body} = await this.getClient(bucket).send(command);
assert(Body != null && Body instanceof Readable);
const stream = Body instanceof PassThrough ? Body : Body.pipe(new PassThrough());
const writeStream = fs.createWriteStream(filePath);
try {
await pipelinePromise(stream, writeStream);
} catch (error) {
writeStream.destroy();
throw error;
}
}
async copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise<void> {
const command = new CopyObjectCommand({
Bucket: destinationBucket,
Key: destinationKey,
CopySource: `${sourceBucket}/${sourceKey}`,
ContentType: newContentType,
MetadataDirective: newContentType ? 'REPLACE' : undefined,
});
await this.getClient(destinationBucket).send(command);
}
async copyObjectWithJpegProcessing({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
contentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
contentType: string;
}): Promise<{width: number; height: number} | null> {
const isJpeg = contentType.toLowerCase().includes('jpeg') || contentType.toLowerCase().includes('jpg');
if (!isJpeg) {
await this.copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType: contentType,
});
return null;
}
try {
const sourceData = await this.readObject(sourceBucket, sourceKey);
return await processAndUploadJpeg({
sourceData,
contentType,
destination: {bucket: destinationBucket, key: destinationKey},
uploadObject: async (params) => this.uploadObject(params),
});
} catch (error) {
Logger.error({error}, 'Failed to process JPEG, falling back to simple copy');
await this.copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType: contentType,
});
return null;
}
}
async moveObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise<void> {
await this.copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
});
await this.deleteObject(sourceBucket, sourceKey);
}
async purgeBucket(bucket: string): Promise<void> {
const command = new ListObjectsV2Command({Bucket: bucket});
const {Contents} = await this.s3.send(command);
if (!Contents) {
return;
}
await Promise.all(Contents.map(({Key}) => Key && this.deleteObject(bucket, Key)));
Logger.debug({bucket}, 'Purged bucket');
}
async uploadAvatar(params: {prefix: string; key: string; body: Uint8Array}): Promise<void> {
const {prefix, key, body} = params;
await this.uploadObject({
bucket: Config.s3.buckets.cdn,
key: `${prefix}/${key}`,
body,
});
}
async deleteAvatar(params: {prefix: string; key: string}): Promise<void> {
const {prefix, key} = params;
await this.deleteObject(Config.s3.buckets.cdn, `${prefix}/${key}`);
}
async getObject(params: {bucket: string; key: string}): Promise<GetObjectCommandOutput> {
const command = new GetObjectCommand({
Bucket: params.bucket,
Key: params.key,
});
return await this.getClient(params.bucket).send(command);
}
async headObject(params: {bucket: string; key: string}): Promise<HeadObjectCommandOutput> {
const command = new HeadObjectCommand({
Bucket: params.bucket,
Key: params.key,
});
return await this.getClient(params.bucket).send(command);
}
async listObjects(params: {bucket: string; prefix: string}): Promise<
ReadonlyArray<{
key: string;
lastModified?: Date;
}>
> {
const allObjects: Array<{key: string; lastModified?: Date}> = [];
let continuationToken: string | undefined;
do {
const command = new ListObjectsV2Command({
Bucket: params.bucket,
Prefix: params.prefix,
ContinuationToken: continuationToken,
});
const response = await this.getClient(params.bucket).send(command);
if (response.Contents) {
for (const obj of response.Contents) {
if (obj.Key) {
allObjects.push({
key: obj.Key,
lastModified: obj.LastModified,
});
}
}
}
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
} while (continuationToken);
return allObjects;
}
async deleteObjects(params: {bucket: string; objects: ReadonlyArray<{Key: string}>}): Promise<void> {
if (params.objects.length === 0) return;
const command = new DeleteObjectsCommand({
Bucket: params.bucket,
Delete: {
Objects: params.objects as Array<{Key: string}>,
},
});
await this.getClient(params.bucket).send(command);
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {DirectS3StorageService} from '@fluxer/api/src/infrastructure/DirectS3StorageService';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import {StorageService} from '@fluxer/api/src/infrastructure/StorageService';
import type {S3Service} from '@fluxer/s3/src/s3/S3Service';
export interface CreateStorageServiceOptions {
s3Service?: S3Service;
}
export function createStorageService(options?: CreateStorageServiceOptions): IStorageService {
if (options?.s3Service) {
return new DirectS3StorageService(options.s3Service);
}
return new StorageService();
}

View File

@@ -0,0 +1,162 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import {IUnfurlerService} from '@fluxer/api/src/infrastructure/IUnfurlerService';
import {Logger} from '@fluxer/api/src/Logger';
import {AudioResolver} from '@fluxer/api/src/unfurler/resolvers/AudioResolver';
import type {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import {BlueskyResolver} from '@fluxer/api/src/unfurler/resolvers/BlueskyResolver';
import {DefaultResolver} from '@fluxer/api/src/unfurler/resolvers/DefaultResolver';
import {HackerNewsResolver} from '@fluxer/api/src/unfurler/resolvers/HackerNewsResolver';
import {ImageResolver} from '@fluxer/api/src/unfurler/resolvers/ImageResolver';
import {KlipyResolver} from '@fluxer/api/src/unfurler/resolvers/KlipyResolver';
import {TenorResolver} from '@fluxer/api/src/unfurler/resolvers/TenorResolver';
import {VideoResolver} from '@fluxer/api/src/unfurler/resolvers/VideoResolver';
import {WikipediaResolver} from '@fluxer/api/src/unfurler/resolvers/WikipediaResolver';
import {XkcdResolver} from '@fluxer/api/src/unfurler/resolvers/XkcdResolver';
import {YouTubeResolver} from '@fluxer/api/src/unfurler/resolvers/YouTubeResolver';
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {FLUXER_USER_AGENT} from '@fluxer/constants/src/Core';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {HTTPException} from 'hono/http-exception';
import {ms} from 'itty-time';
import {filetypemime} from 'magic-bytes.js';
export class UnfurlerService extends IUnfurlerService {
private readonly resolvers: Array<BaseResolver>;
private static readonly UNFURL_REQUEST_HEADERS = {'User-Agent': FLUXER_USER_AGENT};
constructor(
private cacheService: ICacheService,
private mediaService: IMediaService,
) {
super();
this.resolvers = [
new AudioResolver(this.mediaService),
new HackerNewsResolver(this.mediaService),
new ImageResolver(this.mediaService),
new KlipyResolver(this.mediaService),
new TenorResolver(this.mediaService),
new VideoResolver(this.mediaService),
new XkcdResolver(this.mediaService),
new YouTubeResolver(this.mediaService),
new WikipediaResolver(this.mediaService),
new BlueskyResolver(this.cacheService, this.mediaService),
new DefaultResolver(this.cacheService, this.mediaService),
];
}
async unfurl(url: string, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
try {
const originalUrl = new URL(url);
const {fetchUrl, matchingResolver} = this.getUrlToFetch(originalUrl);
const response = await FetchUtils.sendRequest({
url: fetchUrl.href,
timeout: ms('10 seconds'),
headers: UnfurlerService.UNFURL_REQUEST_HEADERS,
});
if (response.status !== 200) {
Logger.debug({url: fetchUrl.href, status: response.status}, 'Non-200 response received');
return [];
}
const contentBuffer = await this.streamToBuffer(response.stream);
const mimeType = this.determineMimeType(contentBuffer, response.headers);
if (!mimeType) {
Logger.error({url: fetchUrl.href}, 'Unable to determine MIME type');
return [];
}
const finalUrl = new URL(response.url);
if (matchingResolver) {
return matchingResolver.resolve(originalUrl, contentBuffer, isNSFWAllowed);
}
for (const resolver of this.resolvers) {
if (resolver.match(finalUrl, mimeType, contentBuffer)) {
if (resolver instanceof DefaultResolver) {
return resolver.resolve(finalUrl, contentBuffer, isNSFWAllowed, {
requestUrl: fetchUrl,
finalUrl,
wasRedirected: fetchUrl.href !== finalUrl.href,
});
}
return resolver.resolve(finalUrl, contentBuffer, isNSFWAllowed);
}
}
return [];
} catch (error) {
Logger.error({error, url}, 'Failed to unfurl URL');
return [];
}
}
private getUrlToFetch(url: URL): {fetchUrl: URL; matchingResolver: BaseResolver | null} {
for (const resolver of this.resolvers) {
const transformedUrl = resolver.transformUrl(url);
if (transformedUrl) {
return {fetchUrl: transformedUrl, matchingResolver: resolver};
}
}
return {fetchUrl: url, matchingResolver: null};
}
private async streamToBuffer(stream: ReadableStream<Uint8Array> | null): Promise<Uint8Array> {
if (!stream) {
return new Uint8Array(0);
}
const MAX_STREAM_BYTES = 500 * 1024 * 1024;
const chunks: Array<Uint8Array> = [];
let totalSize = 0;
const reader = stream.getReader();
try {
while (true) {
const {done, value} = await reader.read();
if (done) break;
if (value) {
totalSize += value.length;
if (totalSize > MAX_STREAM_BYTES) {
throw new HTTPException(413, {
message: 'Stream size exceeds maximum allowed for unfurling',
});
}
chunks.push(value);
}
}
} finally {
reader.releaseLock();
}
const result = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
private determineMimeType(content: Uint8Array, headers: Headers): string | undefined {
const headerMimeType = headers.get('content-type')?.split(';')[0];
if (headerMimeType) return headerMimeType;
const [mimeTypeFromMagicBytes] = filetypemime(new Uint8Array(content));
return mimeTypeFromMagicBytes;
}
}

View File

@@ -0,0 +1,121 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {Logger} from '@fluxer/api/src/Logger';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {mapUserToPartialResponse} from '@fluxer/api/src/user/UserMappers';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {Coalescer} from '@fluxer/cache/src/utils/Coalescer';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {seconds} from 'itty-time';
const USER_PARTIAL_CACHE_TTL = seconds('5 minutes');
export class UserCacheService {
private coalescer = new Coalescer();
constructor(
public readonly cacheService: ICacheService,
private userRepository: IUserRepository,
) {}
private getUserPartialCacheKey(userId: UserID): string {
return `user:partial:${userId}`;
}
private getDeprecatedIncludeDeletedUserPartialCacheKey(userId: UserID): string {
// Kept for cleanup only. We no longer write this variant key.
return `user:partial:include_deleted:${userId}`;
}
async getUserPartialResponse(userId: UserID, requestCache: RequestCache): Promise<UserPartialResponse> {
const cached = requestCache.userPartials.get(userId);
if (cached) {
return cached;
}
const cacheKey = this.getUserPartialCacheKey(userId);
const kvCached = await this.cacheService.getAndRenewTtl<UserPartialResponse>(cacheKey, USER_PARTIAL_CACHE_TTL);
if (kvCached) {
requestCache.userPartials.set(userId, kvCached);
return kvCached;
}
const userPartialResponse = await this.coalescer.coalesce(cacheKey, async () => {
const user = await this.userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if ((user.flags & UserFlags.DELETED) !== 0n && !user.isSystem) {
throw new UnknownUserError();
}
return mapUserToPartialResponse(user);
});
await this.cacheService.set(cacheKey, userPartialResponse, USER_PARTIAL_CACHE_TTL);
requestCache.userPartials.set(userId, userPartialResponse);
return userPartialResponse;
}
async invalidateUserCache(userId: UserID): Promise<void> {
await Promise.all([
this.cacheService.delete(this.getUserPartialCacheKey(userId)),
this.cacheService.delete(this.getDeprecatedIncludeDeletedUserPartialCacheKey(userId)),
]);
}
async getUserPartialResponses(
userIds: Array<UserID>,
requestCache: RequestCache,
): Promise<Map<UserID, UserPartialResponse>> {
const results = new Map<UserID, UserPartialResponse>();
const promises = userIds.map(async (userId) => {
const userResponse = await this.getUserPartialResponse(userId, requestCache);
results.set(userId, userResponse);
});
await Promise.all(promises);
return results;
}
async setUserPartialResponseFromUser(user: User, requestCache?: RequestCache): Promise<UserPartialResponse> {
const response = mapUserToPartialResponse(user);
requestCache?.userPartials.set(user.id, response);
const cacheKey = this.getUserPartialCacheKey(user.id);
await this.cacheService.set(cacheKey, response, USER_PARTIAL_CACHE_TTL);
return response;
}
setUserPartialResponseFromUserInBackground(user: User, requestCache?: RequestCache): UserPartialResponse {
const response = mapUserToPartialResponse(user);
requestCache?.userPartials.set(user.id, response);
const cacheKey = this.getUserPartialCacheKey(user.id);
Promise.resolve(this.cacheService.set(cacheKey, response, USER_PARTIAL_CACHE_TTL)).catch((error) =>
Logger.error({error, cacheKey, userId: user.id}, 'Failed to set user partial cache'),
);
return response;
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import {InstanceConfigRepository} from '@fluxer/api/src/instance/InstanceConfigRepository';
import {Logger} from '@fluxer/api/src/Logger';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {VirusHashCache} from '@fluxer/virus_scan/src/cache/VirusHashCache';
import {WebhookVirusScanFailureReporter} from '@fluxer/virus_scan/src/failures/WebhookVirusScanFailureReporter';
import type {IVirusScanService} from '@fluxer/virus_scan/src/IVirusScanService';
import {ClamAVProvider} from '@fluxer/virus_scan/src/providers/ClamAVProvider';
import type {VirusScanResult} from '@fluxer/virus_scan/src/VirusScanResult';
import {VirusScanService as SharedVirusScanService} from '@fluxer/virus_scan/src/VirusScanService';
export class VirusScanService implements IVirusScanService {
private readonly instanceConfigRepository: InstanceConfigRepository;
private readonly service: SharedVirusScanService;
constructor(cacheService: ICacheService) {
this.instanceConfigRepository = new InstanceConfigRepository();
const provider = new ClamAVProvider({
host: Config.clamav.host,
port: Config.clamav.port,
});
const virusHashCache = new VirusHashCache(cacheService);
const failureReporter = new WebhookVirusScanFailureReporter({
getWebhookUrl: async () => {
const instanceConfig = await this.instanceConfigRepository.getInstanceConfig();
return instanceConfig.systemAlertsWebhookUrl ?? undefined;
},
logger: Logger,
});
this.service = new SharedVirusScanService({
provider,
virusHashCache,
logger: Logger,
config: {
failOpen: Config.clamav.failOpen,
},
failureReporter,
});
}
async initialize(): Promise<void> {
await this.service.initialize();
}
async scanFile(filePath: string): Promise<VirusScanResult> {
return this.service.scanFile(filePath);
}
async scanBuffer(buffer: Buffer, filename: string): Promise<VirusScanResult> {
return this.service.scanBuffer(buffer, filename);
}
}

View File

@@ -0,0 +1,188 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import {createChannelID, createGuildID, createUserID} from '@fluxer/api/src/BrandedTypes';
import {z} from 'zod';
interface DMRoomContext {
readonly type: 'dm';
readonly channelId: ChannelID;
}
interface GuildRoomContext {
readonly type: 'guild';
readonly channelId: ChannelID;
readonly guildId: GuildID;
}
type VoiceRoomContext = DMRoomContext | GuildRoomContext;
interface DMParticipantContext {
readonly type: 'dm';
readonly userId: UserID;
readonly channelId: ChannelID;
readonly connectionId: string;
}
interface GuildParticipantContext {
readonly type: 'guild';
readonly userId: UserID;
readonly channelId: ChannelID;
readonly connectionId: string;
readonly guildId: GuildID;
}
type ParticipantContext = DMParticipantContext | GuildParticipantContext;
const SnowflakeStringSchema = z.string().regex(/^\d+$/, 'Must be a numeric string');
const DMParticipantMetadataSchema = z.object({
user_id: SnowflakeStringSchema,
channel_id: SnowflakeStringSchema,
connection_id: z.string().min(1),
dm_call: z.union([z.literal('true'), z.literal(true)]),
token_nonce: z.string().min(1),
issued_at: z.string().regex(/^\d+$/),
region_id: z.string().optional(),
server_id: z.string().optional(),
});
const GuildParticipantMetadataSchema = z.object({
user_id: SnowflakeStringSchema,
channel_id: SnowflakeStringSchema,
connection_id: z.string().min(1),
guild_id: SnowflakeStringSchema,
token_nonce: z.string().min(1),
issued_at: z.string().regex(/^\d+$/),
region_id: z.string().optional(),
server_id: z.string().optional(),
});
const ParticipantMetadataSchema = z.union([DMParticipantMetadataSchema, GuildParticipantMetadataSchema]);
type RawParticipantMetadata = z.infer<typeof ParticipantMetadataSchema>;
const DM_ROOM_PREFIX = 'dm_channel_';
const GUILD_ROOM_PREFIX = 'guild_';
export function parseRoomName(roomName: string): VoiceRoomContext | null {
if (roomName.startsWith(DM_ROOM_PREFIX)) {
const channelIdStr = roomName.slice(DM_ROOM_PREFIX.length);
try {
return {
type: 'dm',
channelId: createChannelID(BigInt(channelIdStr)),
};
} catch {
return null;
}
}
if (roomName.startsWith(GUILD_ROOM_PREFIX)) {
const parts = roomName.split('_');
if (parts.length === 4 && parts[0] === 'guild' && parts[2] === 'channel') {
try {
return {
type: 'guild',
guildId: createGuildID(BigInt(parts[1])),
channelId: createChannelID(BigInt(parts[3])),
};
} catch {
return null;
}
}
}
return null;
}
export function parseParticipantMetadataWithRaw(
metadata: string,
): {context: ParticipantContext; raw: RawParticipantMetadata} | null {
try {
const parsed = JSON.parse(metadata);
const result = ParticipantMetadataSchema.safeParse(parsed);
if (!result.success) {
return null;
}
const data = result.data;
const userId = createUserID(BigInt(data.user_id));
const channelId = createChannelID(BigInt(data.channel_id));
const connectionId = data.connection_id;
if ('dm_call' in data) {
return {
context: {
type: 'dm',
userId,
channelId,
connectionId,
},
raw: data,
};
}
return {
context: {
type: 'guild',
userId,
channelId,
connectionId,
guildId: createGuildID(BigInt(data.guild_id)),
},
raw: data,
};
} catch {
return null;
}
}
export function isDMRoom(context: VoiceRoomContext): context is DMRoomContext {
return context.type === 'dm';
}
const PARTICIPANT_IDENTITY_PREFIX = 'user_';
interface ParticipantIdentity {
readonly userId: UserID;
readonly connectionId: string;
}
export function parseParticipantIdentity(identity: string): ParticipantIdentity | null {
if (!identity.startsWith(PARTICIPANT_IDENTITY_PREFIX)) {
return null;
}
const parts = identity.split('_');
if (parts.length !== 3 || parts[0] !== 'user') {
return null;
}
try {
return {
userId: createUserID(BigInt(parts[1])),
connectionId: parts[2],
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,146 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID} from '@fluxer/api/src/BrandedTypes';
import {
VOICE_OCCUPANCY_REGION_KEY_PREFIX,
VOICE_OCCUPANCY_SERVER_KEY_PREFIX,
} from '@fluxer/api/src/voice/VoiceConstants';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
export interface PinnedRoomServer {
regionId: string;
serverId: string;
endpoint: string;
}
export class VoiceRoomStore {
private kvClient: IKVProvider;
private readonly keyPrefix = 'voice:room:server';
constructor(kvClient: IKVProvider) {
this.kvClient = kvClient;
}
private getRoomKey(guildId: GuildID | undefined, channelId: ChannelID): string {
if (guildId === undefined) {
return `${this.keyPrefix}:dm:${channelId}`;
}
return `${this.keyPrefix}:guild:${guildId}:${channelId}`;
}
async pinRoomServer(
guildId: GuildID | undefined,
channelId: ChannelID,
regionId: string,
serverId: string,
endpoint: string,
): Promise<void> {
const key = this.getRoomKey(guildId, channelId);
const previous = await this.getPinnedRoomServer(guildId, channelId);
if (previous) {
await this.removeOccupancy(previous.regionId, previous.serverId, guildId, channelId);
}
await this.kvClient.set(
key,
JSON.stringify({
regionId,
serverId,
endpoint,
updatedAt: new Date().toISOString(),
}),
);
await this.addOccupancy(regionId, serverId, guildId, channelId);
}
async getPinnedRoomServer(guildId: GuildID | undefined, channelId: ChannelID): Promise<PinnedRoomServer | null> {
const key = this.getRoomKey(guildId, channelId);
const data = await this.kvClient.get(key);
if (!data) return null;
const parsed = JSON.parse(data) as {regionId?: string; serverId?: string; endpoint?: string};
if (!parsed.regionId || !parsed.serverId || !parsed.endpoint) {
return null;
}
return {
regionId: parsed.regionId,
serverId: parsed.serverId,
endpoint: parsed.endpoint,
};
}
async deleteRoomServer(guildId: GuildID | undefined, channelId: ChannelID): Promise<void> {
const key = this.getRoomKey(guildId, channelId);
const previous = await this.getPinnedRoomServer(guildId, channelId);
await this.kvClient.del(key);
if (previous) {
await this.removeOccupancy(previous.regionId, previous.serverId, guildId, channelId);
}
}
async getRegionOccupancy(regionId: string): Promise<Array<string>> {
const key = `${VOICE_OCCUPANCY_REGION_KEY_PREFIX}:${regionId}`;
const members = await this.kvClient.smembers(key);
return members;
}
async getServerOccupancy(regionId: string, serverId: string): Promise<Array<string>> {
const key = `${VOICE_OCCUPANCY_SERVER_KEY_PREFIX}:${regionId}:${serverId}`;
const members = await this.kvClient.smembers(key);
return members;
}
private async addOccupancy(
regionId: string,
serverId: string,
guildId: GuildID | undefined,
channelId: ChannelID,
): Promise<void> {
const member = this.buildOccupancyMember(guildId, channelId);
const regionKey = `${VOICE_OCCUPANCY_REGION_KEY_PREFIX}:${regionId}`;
const serverKey = `${VOICE_OCCUPANCY_SERVER_KEY_PREFIX}:${regionId}:${serverId}`;
await this.kvClient.multi().sadd(regionKey, member).sadd(serverKey, member).exec();
}
private async removeOccupancy(
regionId: string,
serverId: string,
guildId: GuildID | undefined,
channelId: ChannelID,
): Promise<void> {
const member = this.buildOccupancyMember(guildId, channelId);
const regionKey = `${VOICE_OCCUPANCY_REGION_KEY_PREFIX}:${regionId}`;
const serverKey = `${VOICE_OCCUPANCY_SERVER_KEY_PREFIX}:${regionId}:${serverId}`;
await this.kvClient.multi().srem(regionKey, member).srem(serverKey, member).exec();
}
private buildOccupancyMember(guildId: GuildID | undefined, channelId: ChannelID): string {
if (!guildId) {
return `dm:${channelId.toString()}`;
}
return `guild:${guildId.toString()}:channel:${channelId.toString()}`;
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {hasErrorLocale} from '@fluxer/errors/src/i18n/ErrorI18n';
export type ErrorTranslations = Record<string, string>;
export function getLocaleTranslations(_locale: string): ErrorTranslations {
return {};
}
export function hasLocale(locale: string): boolean {
return hasErrorLocale(locale);
}