refactor progress
This commit is contained in:
124
packages/api/src/infrastructure/AssetDeletionQueue.tsx
Normal file
124
packages/api/src/infrastructure/AssetDeletionQueue.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
432
packages/api/src/infrastructure/AvatarService.tsx
Normal file
432
packages/api/src/infrastructure/AvatarService.tsx
Normal 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(', ');
|
||||
}
|
||||
}
|
||||
122
packages/api/src/infrastructure/ClamAV.tsx
Normal file
122
packages/api/src/infrastructure/ClamAV.tsx
Normal 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}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
202
packages/api/src/infrastructure/CloudflarePurgeQueue.tsx
Normal file
202
packages/api/src/infrastructure/CloudflarePurgeQueue.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
114
packages/api/src/infrastructure/DirectMediaService.tsx
Normal file
114
packages/api/src/infrastructure/DirectMediaService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
235
packages/api/src/infrastructure/DirectS3ExpirationManager.tsx
Normal file
235
packages/api/src/infrastructure/DirectS3ExpirationManager.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
packages/api/src/infrastructure/DirectS3ExpirationStore.tsx
Normal file
117
packages/api/src/infrastructure/DirectS3ExpirationStore.tsx
Normal 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};
|
||||
}
|
||||
}
|
||||
400
packages/api/src/infrastructure/DirectS3StorageService.tsx
Normal file
400
packages/api/src/infrastructure/DirectS3StorageService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
packages/api/src/infrastructure/DisabledLiveKitService.tsx
Normal file
98
packages/api/src/infrastructure/DisabledLiveKitService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
53
packages/api/src/infrastructure/DisabledVirusScanService.tsx
Normal file
53
packages/api/src/infrastructure/DisabledVirusScanService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
292
packages/api/src/infrastructure/DiscriminatorService.tsx
Normal file
292
packages/api/src/infrastructure/DiscriminatorService.tsx
Normal 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?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
461
packages/api/src/infrastructure/EmbedService.tsx
Normal file
461
packages/api/src/infrastructure/EmbedService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
507
packages/api/src/infrastructure/EntityAssetService.tsx
Normal file
507
packages/api/src/infrastructure/EntityAssetService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
packages/api/src/infrastructure/ErrorI18nService.tsx
Normal file
33
packages/api/src/infrastructure/ErrorI18nService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
277
packages/api/src/infrastructure/GatewayRpcClient.tsx
Normal file
277
packages/api/src/infrastructure/GatewayRpcClient.tsx
Normal 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', {});
|
||||
}
|
||||
}
|
||||
44
packages/api/src/infrastructure/GatewayRpcError.tsx
Normal file
44
packages/api/src/infrastructure/GatewayRpcError.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
1295
packages/api/src/infrastructure/GatewayService.tsx
Normal file
1295
packages/api/src/infrastructure/GatewayService.tsx
Normal file
File diff suppressed because it is too large
Load Diff
77
packages/api/src/infrastructure/GatewayTcpFrameCodec.tsx
Normal file
77
packages/api/src/infrastructure/GatewayTcpFrameCodec.tsx
Normal 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),
|
||||
};
|
||||
}
|
||||
528
packages/api/src/infrastructure/GatewayTcpRpcTransport.tsx
Normal file
528
packages/api/src/infrastructure/GatewayTcpRpcTransport.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
47
packages/api/src/infrastructure/IAssetDeletionQueue.tsx
Normal file
47
packages/api/src/infrastructure/IAssetDeletionQueue.tsx
Normal 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>;
|
||||
}
|
||||
23
packages/api/src/infrastructure/IGatewayRpcTransport.tsx
Normal file
23
packages/api/src/infrastructure/IGatewayRpcTransport.tsx
Normal 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>;
|
||||
}
|
||||
271
packages/api/src/infrastructure/IGatewayService.tsx
Normal file
271
packages/api/src/infrastructure/IGatewayService.tsx
Normal 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;
|
||||
}>;
|
||||
}
|
||||
35
packages/api/src/infrastructure/IKlipyService.tsx
Normal file
35
packages/api/src/infrastructure/IKlipyService.tsx
Normal 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>>;
|
||||
}
|
||||
99
packages/api/src/infrastructure/ILiveKitService.tsx
Normal file
99
packages/api/src/infrastructure/ILiveKitService.tsx
Normal 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;
|
||||
}
|
||||
81
packages/api/src/infrastructure/IMediaService.tsx
Normal file
81
packages/api/src/infrastructure/IMediaService.tsx
Normal 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>;
|
||||
}
|
||||
58
packages/api/src/infrastructure/IMetricsService.tsx
Normal file
58
packages/api/src/infrastructure/IMetricsService.tsx
Normal 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;
|
||||
}
|
||||
27
packages/api/src/infrastructure/ISnowflakeService.tsx
Normal file
27
packages/api/src/infrastructure/ISnowflakeService.tsx
Normal 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>;
|
||||
}
|
||||
91
packages/api/src/infrastructure/IStorageService.tsx
Normal file
91
packages/api/src/infrastructure/IStorageService.tsx
Normal 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>;
|
||||
}
|
||||
35
packages/api/src/infrastructure/ITenorService.tsx
Normal file
35
packages/api/src/infrastructure/ITenorService.tsx
Normal 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>>;
|
||||
}
|
||||
24
packages/api/src/infrastructure/IUnfurlerService.tsx
Normal file
24
packages/api/src/infrastructure/IUnfurlerService.tsx
Normal 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>>;
|
||||
}
|
||||
41
packages/api/src/infrastructure/IVoiceRoomStore.tsx
Normal file
41
packages/api/src/infrastructure/IVoiceRoomStore.tsx
Normal 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>>;
|
||||
}
|
||||
47
packages/api/src/infrastructure/InMemoryVoiceRoomStore.tsx
Normal file
47
packages/api/src/infrastructure/InMemoryVoiceRoomStore.tsx
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
}
|
||||
153
packages/api/src/infrastructure/KVActivityTracker.tsx
Normal file
153
packages/api/src/infrastructure/KVActivityTracker.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
403
packages/api/src/infrastructure/LiveKitService.tsx
Normal file
403
packages/api/src/infrastructure/LiveKitService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
725
packages/api/src/infrastructure/LiveKitWebhookService.tsx
Normal file
725
packages/api/src/infrastructure/LiveKitWebhookService.tsx
Normal 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');
|
||||
}
|
||||
}
|
||||
169
packages/api/src/infrastructure/MediaService.tsx
Normal file
169
packages/api/src/infrastructure/MediaService.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
139
packages/api/src/infrastructure/MeilisearchSearchProvider.tsx
Normal file
139
packages/api/src/infrastructure/MeilisearchSearchProvider.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
109
packages/api/src/infrastructure/MetricsService.tsx
Normal file
109
packages/api/src/infrastructure/MetricsService.tsx
Normal 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();
|
||||
}
|
||||
56
packages/api/src/infrastructure/NullSearchProvider.tsx
Normal file
56
packages/api/src/infrastructure/NullSearchProvider.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
262
packages/api/src/infrastructure/SnowflakeService.tsx
Normal file
262
packages/api/src/infrastructure/SnowflakeService.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
163
packages/api/src/infrastructure/StorageObjectHelpers.tsx
Normal file
163
packages/api/src/infrastructure/StorageObjectHelpers.tsx
Normal 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,
|
||||
]);
|
||||
}
|
||||
368
packages/api/src/infrastructure/StorageService.tsx
Normal file
368
packages/api/src/infrastructure/StorageService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
34
packages/api/src/infrastructure/StorageServiceFactory.tsx
Normal file
34
packages/api/src/infrastructure/StorageServiceFactory.tsx
Normal 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();
|
||||
}
|
||||
162
packages/api/src/infrastructure/UnfurlerService.tsx
Normal file
162
packages/api/src/infrastructure/UnfurlerService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
121
packages/api/src/infrastructure/UserCacheService.tsx
Normal file
121
packages/api/src/infrastructure/UserCacheService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
73
packages/api/src/infrastructure/VirusScanService.tsx
Normal file
73
packages/api/src/infrastructure/VirusScanService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
188
packages/api/src/infrastructure/VoiceRoomContext.tsx
Normal file
188
packages/api/src/infrastructure/VoiceRoomContext.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
146
packages/api/src/infrastructure/VoiceRoomStore.tsx
Normal file
146
packages/api/src/infrastructure/VoiceRoomStore.tsx
Normal 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()}`;
|
||||
}
|
||||
}
|
||||
30
packages/api/src/infrastructure/error_i18n/index.tsx
Normal file
30
packages/api/src/infrastructure/error_i18n/index.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user