/*
* 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 .
*/
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;
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 {
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 {
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 {
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 {
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 {
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 {
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 {
return [...extSet].join(', ');
}
}