refactor progress

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

View File

@@ -0,0 +1,46 @@
{
"name": "@fluxer/media_proxy",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/App.tsx",
"./src/types/*": "./src/types/*.tsx",
"./src/utils/*": "./src/utils/*.tsx",
"./*": "./*"
},
"main": "./src/App.tsx",
"types": "./src/App.tsx",
"scripts": {
"test": "vitest run",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "catalog:",
"@fluxer/constants": "workspace:*",
"@fluxer/errors": "workspace:*",
"@fluxer/hono": "workspace:*",
"@fluxer/hono_types": "workspace:*",
"@fluxer/http_client": "workspace:*",
"@fluxer/logger": "workspace:*",
"@fluxer/media_proxy_utils": "workspace:*",
"@fluxer/mime_utils": "workspace:*",
"@fluxer/rate_limit": "workspace:*",
"@fluxer/schema": "workspace:*",
"@fluxer/sentry": "workspace:*",
"hono": "catalog:",
"magic-bytes.js": "catalog:",
"onnxruntime-node": "catalog:",
"sharp": "catalog:",
"tempy": "catalog:",
"thumbhash": "catalog:",
"valibot": "catalog:"
},
"devDependencies": {
"@opentelemetry/api": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -0,0 +1,387 @@
/*
* 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 {createErrorHandler} from '@fluxer/errors/src/ErrorHandler';
import {applyMiddlewareStack} from '@fluxer/hono/src/middleware/MiddlewareStack';
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import {createAttachmentsHandler} from '@fluxer/media_proxy/src/controllers/AttachmentsController';
import {createExternalMediaHandler} from '@fluxer/media_proxy/src/controllers/ExternalMediaController';
import {createFrameExtractionHandler} from '@fluxer/media_proxy/src/controllers/FrameExtractionController';
import {
createGuildMemberImageRouteHandler,
createImageRouteHandler,
createSimpleImageRouteHandler,
} from '@fluxer/media_proxy/src/controllers/ImageController';
import {createMetadataHandler} from '@fluxer/media_proxy/src/controllers/MetadataController';
import {createStaticProxyHandler} from '@fluxer/media_proxy/src/controllers/StaticProxyController';
import {createStickerRouteHandler} from '@fluxer/media_proxy/src/controllers/StickerController';
import {createThemeHandler} from '@fluxer/media_proxy/src/controllers/ThemeController';
import {createThumbnailHandler} from '@fluxer/media_proxy/src/controllers/ThumbnailController';
import {CloudflareEdgeIPService} from '@fluxer/media_proxy/src/lib/CloudflareEdgeIPService';
import {createCodecValidator} from '@fluxer/media_proxy/src/lib/CodecValidation';
import {createFFmpegUtils, createFrameExtractor} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
import {createImageProcessor} from '@fluxer/media_proxy/src/lib/ImageProcessing';
import {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
import {createMediaTransformService} from '@fluxer/media_proxy/src/lib/MediaTransformService';
import {createMediaValidator} from '@fluxer/media_proxy/src/lib/MediaValidation';
import {createMimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
import {NSFWDetectionService} from '@fluxer/media_proxy/src/lib/NSFWDetectionService';
import {createS3Client, createS3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
import {createInternalNetworkRequired} from '@fluxer/media_proxy/src/middleware/AuthMiddleware';
import {createCloudflareFirewall} from '@fluxer/media_proxy/src/middleware/CloudflareFirewall';
import {createMetricsMiddleware} from '@fluxer/media_proxy/src/middleware/MetricsMiddleware';
import {createFrameService} from '@fluxer/media_proxy/src/services/FrameService';
import {createMetadataService} from '@fluxer/media_proxy/src/services/MetadataService';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {MediaProxyConfig} from '@fluxer/media_proxy/src/types/MediaProxyConfig';
import type {MediaProxyServices} from '@fluxer/media_proxy/src/types/MediaProxyServices';
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
import {createHttpClient} from '@fluxer/media_proxy/src/utils/FetchUtils';
import type {RateLimitService} from '@fluxer/rate_limit/src/RateLimitService';
import {captureException} from '@fluxer/sentry/src/Sentry';
import {Hono} from 'hono';
import {HTTPException} from 'hono/http-exception';
import * as v from 'valibot';
const FLUXER_USER_AGENT = 'Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)';
export interface CreateMediaProxyAppOptions {
config: MediaProxyConfig;
logger: LoggerInterface;
metrics?: MetricsInterface;
tracing?: TracingInterface;
requestMetricsCollector?: MetricsCollector;
requestTracing?: TracingOptions;
onTelemetryRequest?: () => Promise<{telemetry_enabled: boolean; service: string; timestamp: string}>;
rateLimitService?: RateLimitService | null;
rateLimitConfig?: {
enabled: boolean;
maxAttempts: number;
windowMs: number;
skipPaths?: Array<string>;
} | null;
publicOnly?: boolean;
}
export interface MediaProxyAppResult {
app: Hono<HonoEnv>;
shutdown: () => Promise<void>;
services?: MediaProxyServices;
}
export async function createMediaProxyApp(options: CreateMediaProxyAppOptions): Promise<MediaProxyAppResult> {
const {
config,
logger,
metrics,
tracing,
requestMetricsCollector,
requestTracing,
onTelemetryRequest,
rateLimitService,
rateLimitConfig,
publicOnly = false,
} = options;
const app = new Hono<HonoEnv>({strict: true});
applyMiddlewareStack(app, {
requestId: {},
tracing: requestTracing,
metrics: requestMetricsCollector
? {
enabled: true,
collector: requestMetricsCollector,
skipPaths: ['/_health', '/internal/telemetry'],
}
: undefined,
logger: {
log: (data) => {
if (data.path !== '/_health' && data.path !== '/internal/telemetry') {
logger.info(
{
method: data.method,
path: data.path,
status: data.status,
durationMs: data.durationMs,
},
'Request completed',
);
}
},
},
rateLimit:
rateLimitService && rateLimitConfig?.enabled
? {
enabled: true,
service: rateLimitService,
maxAttempts: rateLimitConfig.maxAttempts,
windowMs: rateLimitConfig.windowMs,
skipPaths: rateLimitConfig.skipPaths ?? ['/_health'],
}
: undefined,
customMiddleware: [
async (ctx, next) => {
ctx.set('tempFiles', []);
try {
await next();
} finally {
const tempFiles = ctx.get('tempFiles') as Array<string>;
await Promise.all(
tempFiles.map((file: string) =>
fs.unlink(file).catch(() => logger.error(`Failed to delete temp file: ${file}`)),
),
);
}
},
],
skipErrorHandler: true,
});
if (metrics) {
app.use('*', createMetricsMiddleware(metrics));
}
const s3Client = createS3Client(config.s3);
const s3Utils = createS3Utils(s3Client, metrics);
const mimeTypeUtils = createMimeTypeUtils(logger);
const codecValidator = createCodecValidator(logger);
const ffmpegUtils = createFFmpegUtils({metrics, tracing});
const frameExtractor = createFrameExtractor({tracing});
const imageProcessor = createImageProcessor({metrics, tracing});
const mediaValidator = createMediaValidator(mimeTypeUtils, codecValidator, ffmpegUtils);
const mediaTransformService = createMediaTransformService({imageProcessor, ffmpegUtils, mimeTypeUtils});
const httpClient = createHttpClient(FLUXER_USER_AGENT, {metrics, tracing});
const coalescer = new InMemoryCoalescer({metrics, tracing});
logger.info('Initialized in-memory request coalescer');
const fetchForCloudflare = async (url: string, method: string) => {
const response = await httpClient.sendRequest({url, method: method as 'GET' | 'POST' | 'HEAD'});
return {status: response.status, stream: response.stream};
};
const cloudflareEdgeIPService = new CloudflareEdgeIPService(logger, fetchForCloudflare);
if (config.requireCloudflareEdge) {
await cloudflareEdgeIPService.initialize();
logger.info('Initialized Cloudflare edge IP allowlist');
} else {
logger.info('Cloudflare edge IP allowlist disabled');
}
const cloudflareFirewall = createCloudflareFirewall(cloudflareEdgeIPService, logger, {
enabled: config.requireCloudflareEdge,
});
app.use('*', cloudflareFirewall);
app.get('/_health', (ctx) => ctx.text('OK'));
app.get('/internal/telemetry', async (ctx) => {
if (onTelemetryRequest) {
return ctx.json(await onTelemetryRequest());
}
return ctx.json({
telemetry_enabled: Boolean(metrics),
service: 'fluxer_media_proxy',
timestamp: new Date().toISOString(),
});
});
let exposedServices: MediaProxyServices | undefined;
if (config.staticMode) {
logger.info('Media proxy running in STATIC MODE - proxying all requests to the static bucket');
const handleStaticProxyRequest = createStaticProxyHandler({
s3Utils,
bucketStatic: config.s3.bucketStatic,
});
app.all('*', handleStaticProxyRequest);
} else {
const nsfwDetectionService = new NSFWDetectionService({
modelPath: config.nsfwModelPath,
nodeEnv: config.nodeEnv,
});
await nsfwDetectionService.initialize();
logger.info('Initialized NSFW detection service');
const metadataService = createMetadataService({
coalescer,
nsfwDetectionService,
s3Utils,
httpClient,
mimeTypeUtils,
mediaValidator,
ffmpegUtils,
logger,
bucketUploads: config.s3.bucketUploads,
});
const frameService = createFrameService({
s3Utils,
mimeTypeUtils,
frameExtractor,
logger,
bucketUploads: config.s3.bucketUploads,
});
exposedServices = {metadataService, frameService};
const imageControllerDeps = {
coalescer,
s3Utils,
mimeTypeUtils,
imageProcessor,
bucketCdn: config.s3.bucketCdn,
};
const handleImageRoute = createImageRouteHandler(imageControllerDeps);
const handleSimpleImageRoute = createSimpleImageRouteHandler(imageControllerDeps);
const handleGuildMemberImageRoute = createGuildMemberImageRouteHandler(imageControllerDeps);
const handleStickerRoute = createStickerRouteHandler({
coalescer,
s3Utils,
bucketCdn: config.s3.bucketCdn,
});
const processExternalMedia = createExternalMediaHandler({
coalescer,
httpClient,
mimeTypeUtils,
mediaValidator,
mediaTransformService,
logger,
secretKey: config.secretKey,
metrics,
tracing,
});
const handleAttachmentsRoute = createAttachmentsHandler({
coalescer,
s3Client,
s3Utils,
mimeTypeUtils,
mediaValidator,
mediaTransformService,
logger,
bucketCdn: config.s3.bucketCdn,
});
const handleThemeRequest = createThemeHandler({
s3Utils,
bucketCdn: config.s3.bucketCdn,
});
if (!publicOnly) {
const InternalNetworkRequired = createInternalNetworkRequired(config.secretKey);
const handleMetadataRequest = createMetadataHandler({
metadataService,
logger,
});
const handleThumbnailRequest = createThumbnailHandler({
s3Utils,
mimeTypeUtils,
ffmpegUtils,
logger,
bucketUploads: config.s3.bucketUploads,
});
const handleFrameExtraction = createFrameExtractionHandler({
frameService,
logger,
});
app.post('/_metadata', InternalNetworkRequired, handleMetadataRequest);
app.post('/_thumbnail', InternalNetworkRequired, handleThumbnailRequest);
app.post('/_frames', InternalNetworkRequired, handleFrameExtraction);
}
app.get('/avatars/:id/:filename', async (ctx) => handleImageRoute(ctx, 'avatars'));
app.get('/icons/:id/:filename', async (ctx) => handleImageRoute(ctx, 'icons'));
app.get('/banners/:id/:filename', async (ctx) => handleImageRoute(ctx, 'banners'));
app.get('/splashes/:id/:filename', async (ctx) => handleImageRoute(ctx, 'splashes'));
app.get('/embed-splashes/:id/:filename', async (ctx) => handleImageRoute(ctx, 'embed-splashes'));
app.get('/emojis/:id', async (ctx) => handleSimpleImageRoute(ctx, 'emojis'));
app.get('/stickers/:id', handleStickerRoute);
app.get('/guilds/:guild_id/users/:user_id/avatars/:filename', async (ctx) =>
handleGuildMemberImageRoute(ctx, 'avatars'),
);
app.get('/guilds/:guild_id/users/:user_id/banners/:filename', async (ctx) =>
handleGuildMemberImageRoute(ctx, 'banners'),
);
app.get('/attachments/:channel_id/:attachment_id/:filename', handleAttachmentsRoute);
app.get('/themes/:id.css', handleThemeRequest);
app.get('/external/*', async (ctx) => {
const fullPath = ctx.req.path;
const externalIndex = fullPath.indexOf('/external/');
if (externalIndex === -1) throw new HTTPException(400);
const path = fullPath.substring(externalIndex + '/external/'.length);
return processExternalMedia(ctx, path);
});
}
const errorHandler = createErrorHandler({
includeStack: false,
logError: (err: unknown) => {
const isExpectedError = err instanceof Error && 'isExpected' in err && err.isExpected;
if (!(v.isValiError(err) || err instanceof SyntaxError || err instanceof HTTPException || isExpectedError)) {
if (err instanceof Error) {
captureException(err);
}
logger.error({err}, 'Unexpected error occurred');
}
},
customHandler: (err: unknown, ctx) => {
const isExpectedError = err instanceof Error && 'isExpected' in err && err.isExpected;
if (v.isValiError(err) || err instanceof SyntaxError) {
return ctx.text('Bad Request', {status: 400});
}
if (err instanceof HTTPException) {
return err.getResponse();
}
if (isExpectedError) {
logger.warn({err}, 'Expected error occurred');
return ctx.text('Bad Request', {status: 400});
}
logger.error({err}, 'Unhandled error occurred');
return ctx.text('Internal Server Error', {status: 500});
},
});
app.onError(errorHandler);
const shutdown = async () => {
logger.info('Shutting down media proxy');
cloudflareEdgeIPService.shutdown();
};
return {app, shutdown, services: exposedServices};
}

View File

@@ -0,0 +1,162 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import assert from 'node:assert/strict';
import {Stream} from 'node:stream';
import {HeadObjectCommand, type S3Client} from '@aws-sdk/client-s3';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import {toBodyData, toWebReadableStream} from '@fluxer/media_proxy/src/lib/BinaryUtils';
import {parseRange, setHeaders} from '@fluxer/media_proxy/src/lib/HttpUtils';
import type {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
import type {MediaTransformService} from '@fluxer/media_proxy/src/lib/MediaTransformService';
import {SUPPORTED_MIME_TYPES} from '@fluxer/media_proxy/src/lib/MediaTypes';
import type {MediaValidator} from '@fluxer/media_proxy/src/lib/MediaValidation';
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
import type {S3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
import {ExternalQuerySchema} from '@fluxer/media_proxy/src/schemas/ValidationSchemas';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {Context} from 'hono';
import {HTTPException} from 'hono/http-exception';
import * as v from 'valibot';
interface AttachmentsControllerDeps {
coalescer: InMemoryCoalescer;
s3Client: S3Client;
s3Utils: S3Utils;
mimeTypeUtils: MimeTypeUtils;
mediaValidator: MediaValidator;
mediaTransformService: MediaTransformService;
logger: LoggerInterface;
bucketCdn: string;
}
export function createAttachmentsHandler(deps: AttachmentsControllerDeps) {
const {coalescer, s3Client, s3Utils, mimeTypeUtils, mediaValidator, mediaTransformService, logger, bucketCdn} = deps;
const {readS3Object} = s3Utils;
const {getMimeType, getMediaCategory, getContentType} = mimeTypeUtils;
const {validateMedia} = mediaValidator;
const {transformImage, transformVideoThumbnail} = mediaTransformService;
return async (ctx: Context<HonoEnv>): Promise<Response> => {
const {channel_id, attachment_id, filename} = ctx.req.param();
if (!filename) throw new HTTPException(400);
const {width, height, format, quality, animated} = v.parse(ExternalQuerySchema, ctx.req.query());
const key = `attachments/${channel_id}/${attachment_id}/${filename}`;
const isStreamableMedia = /\.(mp3|wav|ogg|flac|m4a|aac|opus|wma|mp4|webm|mov|avi|mkv|m4v)$/i.test(filename);
const hasTransformations = width || height || format || quality !== 'lossless' || animated;
if (
(isStreamableMedia && !hasTransformations) ||
(!width && !height && !format && quality === 'lossless' && !animated)
) {
try {
const headCommand = new HeadObjectCommand({
Bucket: bucketCdn,
Key: key,
});
const headResponse = await s3Client.send(headCommand);
const totalSize = headResponse.ContentLength || 0;
const range = parseRange(ctx.req.header('Range') ?? '', totalSize);
let streamData: Stream;
let lastModified: Date | undefined;
if (range) {
const result = await readS3Object(bucketCdn, key, range);
assert(result.data instanceof Stream, 'Expected range request to return a stream');
streamData = result.data;
lastModified = result.lastModified;
} else {
const result = await s3Utils.streamS3Object(bucketCdn, key);
streamData = result.stream;
lastModified = result.lastModified;
}
const contentType = getContentType(filename);
setHeaders(ctx, totalSize, contentType, range, lastModified);
ctx.header('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
return new Response(toWebReadableStream(streamData), {
status: range ? 206 : 200,
headers: Object.fromEntries(ctx.res.headers),
});
} catch (error) {
logger.error({error}, 'Failed to process attachment media');
throw new HTTPException(400);
}
}
const normalizedFormat = format ? format.toLowerCase() : '';
const cacheKey = `${key}_${width}_${height}_${normalizedFormat}_${quality}_${animated}`;
const result = await coalescer.coalesce(cacheKey, async () => {
try {
const {data} = await readS3Object(bucketCdn, key);
assert(data instanceof Buffer);
const mimeType = getMimeType(data, filename);
const contentType = getContentType(filename);
if (mimeType && SUPPORTED_MIME_TYPES.has(mimeType)) {
await validateMedia(data, filename);
}
const mediaType = mimeType ? getMediaCategory(mimeType) : null;
if (!mediaType) throw new HTTPException(400, {message: 'Invalid media type'});
if (mediaType === 'image') {
return transformImage(data, {
width,
height,
format: normalizedFormat || undefined,
quality,
animated,
fallbackContentType: contentType,
});
}
if (mediaType === 'video' && format && mimeType) {
return transformVideoThumbnail(data, mimeType, {
width,
height,
format,
quality,
});
}
throw new HTTPException(400, {message: 'Only images can be transformed via this endpoint'});
} catch (error) {
logger.error({error}, 'Failed to process attachment media');
throw new HTTPException(400);
}
});
const range = parseRange(ctx.req.header('Range') ?? '', result.data.length);
setHeaders(ctx, result.data.length, result.contentType, range);
const downloadFilename = format && filename ? filename.replace(/\.[^.]+$/, `.${format}`) : (filename ?? 'file');
ctx.header('Content-Disposition', `attachment; filename="${encodeURIComponent(downloadFilename)}"`);
const fileData = range ? result.data.subarray(range.start, range.end + 1) : result.data;
return ctx.body(toBodyData(fileData));
};
}

View File

@@ -0,0 +1,186 @@
/*
* 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 {HttpClient} from '@fluxer/http_client/src/HttpClientTypes';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import {createExternalMediaHandler} from '@fluxer/media_proxy/src/controllers/ExternalMediaController';
import {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
import type {MediaTransformService} from '@fluxer/media_proxy/src/lib/MediaTransformService';
import type {MediaValidator} from '@fluxer/media_proxy/src/lib/MediaValidation';
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import {buildExternalMediaProxyPath} from '@fluxer/media_proxy_utils/src/ExternalMediaProxyPathCodec';
import {createSignature} from '@fluxer/media_proxy_utils/src/MediaProxyUtils';
import {Hono} from 'hono';
import {describe, expect, test, vi} from 'vitest';
const SOURCE_URL = 'https://media.tenor.com/HozyHCAac-kAAAAM/high-five-patrick-star.gif';
const SECRET_KEY = 'test-secret';
function createReadableStream(buffer: Buffer): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(buffer));
controller.close();
},
});
}
function createNoopLogger(): LoggerInterface {
function trace(_obj: Record<string, unknown> | string, _msg?: string): void {}
function debug(_obj: Record<string, unknown> | string, _msg?: string): void {}
function info(_obj: Record<string, unknown> | string, _msg?: string): void {}
function warn(_obj: Record<string, unknown> | string, _msg?: string): void {}
function error(_obj: Record<string, unknown> | string, _msg?: string): void {}
const logger: LoggerInterface = {
trace,
debug,
info,
warn,
error,
child: () => logger,
};
return logger;
}
function createMockMimeTypeUtils(): MimeTypeUtils {
return {
getMimeType: vi.fn(() => 'image/gif'),
generateFilename: vi.fn(() => 'high-five-patrick-star.gif'),
getMediaCategory: vi.fn(() => 'image'),
getContentType: vi.fn(() => 'image/gif'),
};
}
function createMockMediaValidator(): MediaValidator {
return {
validateMedia: vi.fn(async () => 'image/gif'),
processMetadata: vi.fn(),
};
}
function createMockHttpClient(sourceBuffer: Buffer): HttpClient {
const sendRequest = vi.fn(async () => ({
stream: createReadableStream(sourceBuffer),
headers: new Headers(),
status: 200,
url: SOURCE_URL,
}));
return {
request: sendRequest,
sendRequest,
streamToString: vi.fn(async () => ''),
};
}
function createExternalProxyApp(params: {
httpClient: HttpClient;
mimeTypeUtils: MimeTypeUtils;
mediaValidator: MediaValidator;
mediaTransformService: MediaTransformService;
}): Hono<HonoEnv> {
const app = new Hono<HonoEnv>();
const handler = createExternalMediaHandler({
coalescer: new InMemoryCoalescer(),
httpClient: params.httpClient,
mimeTypeUtils: params.mimeTypeUtils,
mediaValidator: params.mediaValidator,
mediaTransformService: params.mediaTransformService,
logger: createNoopLogger(),
secretKey: SECRET_KEY,
});
app.get('/external/*', async (ctx) => {
const fullPath = ctx.req.path;
const externalIndex = fullPath.indexOf('/external/');
const path = fullPath.substring(externalIndex + '/external/'.length);
return handler(ctx, path);
});
return app;
}
function createSignedProxyPath(url: string): string {
const proxyUrlPath = buildExternalMediaProxyPath(url);
const signature = createSignature(proxyUrlPath, SECRET_KEY);
return `/external/${signature}/${proxyUrlPath}`;
}
describe('external media controller', () => {
test('returns the original GIF bytes when no transformations are requested', async () => {
const sourceBuffer = Buffer.from('GIF89a source', 'utf8');
const transformedBuffer = Buffer.from('GIF89a transformed', 'utf8');
const httpClient = createMockHttpClient(sourceBuffer);
const mimeTypeUtils = createMockMimeTypeUtils();
const mediaValidator = createMockMediaValidator();
const mediaTransformService: MediaTransformService = {
transformImage: vi.fn(async () => ({data: transformedBuffer, contentType: 'image/gif'})),
transformVideoThumbnail: vi.fn(),
};
const app = createExternalProxyApp({
httpClient,
mimeTypeUtils,
mediaValidator,
mediaTransformService,
});
const response = await app.request(createSignedProxyPath(SOURCE_URL));
const responseBody = Buffer.from(await response.arrayBuffer());
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('image/gif');
expect(responseBody.equals(sourceBuffer)).toBe(true);
expect(httpClient.sendRequest).toHaveBeenCalledWith({url: SOURCE_URL});
expect(mediaTransformService.transformImage).not.toHaveBeenCalled();
});
test('transforms when explicit image transformations are requested', async () => {
const sourceBuffer = Buffer.from('GIF89a source', 'utf8');
const transformedBuffer = Buffer.from('GIF89a transformed', 'utf8');
const httpClient = createMockHttpClient(sourceBuffer);
const mimeTypeUtils = createMockMimeTypeUtils();
const mediaValidator = createMockMediaValidator();
const mediaTransformService: MediaTransformService = {
transformImage: vi.fn(async () => ({data: transformedBuffer, contentType: 'image/gif'})),
transformVideoThumbnail: vi.fn(),
};
const app = createExternalProxyApp({
httpClient,
mimeTypeUtils,
mediaValidator,
mediaTransformService,
});
const response = await app.request(`${createSignedProxyPath(SOURCE_URL)}?animated=true`);
const responseBody = Buffer.from(await response.arrayBuffer());
expect(response.status).toBe(200);
expect(responseBody.equals(transformedBuffer)).toBe(true);
expect(mediaTransformService.transformImage).toHaveBeenCalledTimes(1);
expect(mediaTransformService.transformImage).toHaveBeenCalledWith(sourceBuffer, {
width: undefined,
height: undefined,
format: undefined,
quality: 'lossless',
animated: true,
fallbackContentType: 'image/gif',
});
});
});

View File

@@ -0,0 +1,195 @@
/*
* 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 {HttpClient} from '@fluxer/http_client/src/HttpClientTypes';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import {toBodyData} from '@fluxer/media_proxy/src/lib/BinaryUtils';
import {parseRange, setHeaders} from '@fluxer/media_proxy/src/lib/HttpUtils';
import type {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
import type {MediaTransformService} from '@fluxer/media_proxy/src/lib/MediaTransformService';
import type {MediaValidator} from '@fluxer/media_proxy/src/lib/MediaValidation';
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
import {streamToBuffer} from '@fluxer/media_proxy/src/lib/S3Utils';
import {ExternalQuerySchema} from '@fluxer/media_proxy/src/schemas/ValidationSchemas';
import type {ErrorType, HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
import {reconstructOriginalUrl} from '@fluxer/media_proxy_utils/src/ExternalMediaProxyPathCodec';
import {verifySignature} from '@fluxer/media_proxy_utils/src/MediaProxyUtils';
import type {Context} from 'hono';
import {HTTPException} from 'hono/http-exception';
import * as v from 'valibot';
interface ExternalMediaControllerDeps {
coalescer: InMemoryCoalescer;
httpClient: HttpClient;
mimeTypeUtils: MimeTypeUtils;
mediaValidator: MediaValidator;
mediaTransformService: MediaTransformService;
logger: LoggerInterface;
secretKey: string;
metrics?: MetricsInterface | undefined;
tracing?: TracingInterface | undefined;
}
function getErrorTypeFromUpstreamStatus(status: number): ErrorType {
if (status >= 500) return 'upstream_5xx';
if (status === 404) return 'not_found';
if (status === 403) return 'forbidden';
if (status === 401) return 'unauthorized';
return 'other';
}
export function createExternalMediaHandler(deps: ExternalMediaControllerDeps) {
const {
coalescer,
httpClient,
mimeTypeUtils,
mediaValidator,
mediaTransformService,
logger,
secretKey,
metrics,
tracing,
} = deps;
const {getMimeType, generateFilename, getMediaCategory} = mimeTypeUtils;
const {validateMedia} = mediaValidator;
const {transformImage, transformVideoThumbnail} = mediaTransformService;
const fetchAndValidate = async (
url: string,
ctx: Context<HonoEnv>,
): Promise<{buffer: Buffer; mimeType: string; filename: string}> => {
try {
const response = await httpClient.sendRequest({url});
if (response.status !== 200) {
const errorType = getErrorTypeFromUpstreamStatus(response.status);
metrics?.counter({
name: 'media_proxy.external.upstream_error',
dimensions: {status: String(response.status), error_type: errorType},
});
ctx.set('metricsErrorContext', {errorType, errorSource: 'upstream'});
throw new Error(`Failed to fetch media: ${response.status}`);
}
const buffer = await streamToBuffer(response.stream);
const urlObj = new URL(url);
const filename = urlObj.pathname.substring(urlObj.pathname.lastIndexOf('/') + 1);
const mimeType = getMimeType(buffer, filename);
if (!mimeType) throw new HTTPException(400, {message: 'Unsupported file format'});
const effectiveFilename = filename?.includes('.') ? filename : generateFilename(mimeType, filename);
await validateMedia(buffer, effectiveFilename);
return {buffer, mimeType, filename: effectiveFilename};
} catch (error) {
if (error instanceof HTTPException) throw error;
if (error instanceof Error && 'isExpected' in error && error.isExpected) {
const httpError = error as Error & {errorType?: ErrorType};
if (httpError.errorType) {
ctx.set('metricsErrorContext', {errorType: httpError.errorType, errorSource: 'network'});
}
throw new HTTPException(400, {message: `Unable to fetch media: ${error.message}`});
}
throw error;
}
};
return async (ctx: Context<HonoEnv>, path: string): Promise<Response> => {
const {width, height, format, quality, animated} = v.parse(ExternalQuerySchema, ctx.req.query());
const parts = path.split('/');
const signature = parts[0];
const proxyUrlPath = parts.slice(1).join('/');
const hasTransformations = Boolean(width || height || format || quality !== 'lossless' || animated);
if (!signature || !proxyUrlPath) throw new HTTPException(400);
if (!verifySignature(proxyUrlPath, signature, secretKey)) {
throw new HTTPException(401);
}
const normalizedFormat = format ? format.toLowerCase() : '';
const cacheKey = `${proxyUrlPath}_${signature}_${width}_${height}_${normalizedFormat}_${quality}_${animated}`;
const result = await coalescer.coalesce(cacheKey, async () => {
const fn = async () => {
try {
const actualUrl = reconstructOriginalUrl(proxyUrlPath);
const {buffer, mimeType} = await fetchAndValidate(actualUrl, ctx);
const mediaType = getMediaCategory(mimeType);
if (!mediaType) throw new HTTPException(400, {message: 'Invalid media type'});
if (!hasTransformations) {
return {data: buffer, contentType: mimeType};
}
if (mediaType === 'image') {
tracing?.addSpanEvent('image.process.start', {mimeType});
return transformImage(buffer, {
width,
height,
format: normalizedFormat || undefined,
quality,
animated,
fallbackContentType: mimeType,
});
}
if (mediaType === 'video' && format) {
tracing?.addSpanEvent('video.thumb.start', {mimeType});
return transformVideoThumbnail(buffer, mimeType, {
width,
height,
format,
quality,
});
}
return {data: buffer, contentType: mimeType};
} catch (error) {
if (error instanceof HTTPException) throw error;
logger.error({error}, 'Failed to process external media');
throw new HTTPException(400, {message: 'Failed to process media'});
}
};
if (tracing) {
return tracing.withSpan(
{
name: 'media_proxy.external.process',
attributes: {
'media.proxy.path': proxyUrlPath,
'media.proxy.cache_key': cacheKey,
'media.request.format': normalizedFormat || 'original',
},
},
fn,
);
}
return fn();
});
const range = parseRange(ctx.req.header('Range') ?? '', result.data.length);
setHeaders(ctx, result.data.length, result.contentType, range);
const fileData = range ? result.data.subarray(range.start, range.end + 1) : result.data;
return ctx.body(toBodyData(fileData));
};
}

View File

@@ -0,0 +1,60 @@
/*
* 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 {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {IFrameService} from '@fluxer/media_proxy/src/types/MediaProxyServices';
import type {Context} from 'hono';
import {HTTPException} from 'hono/http-exception';
import * as v from 'valibot';
const FrameExtractionRequestSchema = v.union([
v.object({
type: v.literal('upload'),
upload_filename: v.string(),
}),
v.object({
type: v.literal('s3'),
bucket: v.string(),
key: v.string(),
}),
]);
interface FrameExtractionControllerDeps {
frameService: IFrameService;
logger: LoggerInterface;
}
export function createFrameExtractionHandler(deps: FrameExtractionControllerDeps) {
const {frameService, logger} = deps;
return async (ctx: Context<HonoEnv>): Promise<Response> => {
try {
const body = await ctx.req.json();
const request = v.parse(FrameExtractionRequestSchema, body);
const result = await frameService.extractFrames(request);
return ctx.json(result);
} catch (error) {
if (error instanceof HTTPException) throw error;
if (v.isValiError(error)) throw error;
logger.error({error}, 'Failed to extract media frames');
throw new HTTPException(500, {message: error instanceof Error ? error.message : 'Unable to extract frames'});
}
};
}

View File

@@ -0,0 +1,176 @@
/*
* 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 {PassThrough} from 'node:stream';
import {
createGuildMemberImageRouteHandler,
createImageRouteHandler,
} from '@fluxer/media_proxy/src/controllers/ImageController';
import type {ImageProcessor} from '@fluxer/media_proxy/src/lib/ImageProcessing';
import {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
import type {S3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import {Hono} from 'hono';
import {HTTPException} from 'hono/http-exception';
import sharp from 'sharp';
import {describe, expect, test, vi} from 'vitest';
async function createImageBuffer(): Promise<Buffer<ArrayBuffer>> {
const sourceBuffer = await sharp({
create: {
width: 16,
height: 16,
channels: 4,
background: {r: 0, g: 128, b: 255, alpha: 1},
},
})
.png()
.toBuffer();
return Buffer.from(sourceBuffer);
}
function createMockS3Utils(imageBuffer: Buffer<ArrayBuffer>, readS3Object: S3Utils['readS3Object']): S3Utils {
return {
headS3Object: async () => ({
contentLength: imageBuffer.length,
contentType: 'image/png',
lastModified: undefined,
}),
readS3Object,
streamS3Object: async () => {
const stream = new PassThrough();
stream.end(imageBuffer);
return {
stream,
size: imageBuffer.length,
contentType: 'image/png',
lastModified: undefined,
};
},
};
}
function createMockImageProcessor(imageBuffer: Buffer<ArrayBuffer>): ImageProcessor {
return {
processImage: vi.fn(async () => imageBuffer),
};
}
function createMockMimeTypeUtils(): MimeTypeUtils {
return {
getMimeType: vi.fn(() => 'image/webp'),
generateFilename: vi.fn(() => 'image.webp'),
getMediaCategory: vi.fn(() => 'image'),
getContentType: vi.fn(() => 'image/webp'),
};
}
function createImageApp(params: {
readS3Object: S3Utils['readS3Object'];
imageBuffer: Buffer<ArrayBuffer>;
}): Hono<HonoEnv> {
const {readS3Object, imageBuffer} = params;
const app = new Hono<HonoEnv>();
const deps = {
coalescer: new InMemoryCoalescer(),
s3Utils: createMockS3Utils(imageBuffer, readS3Object),
mimeTypeUtils: createMockMimeTypeUtils(),
imageProcessor: createMockImageProcessor(imageBuffer),
bucketCdn: 'cdn-bucket',
};
app.get('/avatars/:id/:filename', async (ctx) => createImageRouteHandler(deps)(ctx, 'avatars'));
app.get('/guilds/:guild_id/users/:user_id/avatars/:filename', async (ctx) =>
createGuildMemberImageRouteHandler(deps)(ctx, 'avatars'),
);
return app;
}
describe('image controller', () => {
test('falls back to extension-suffixed avatar key', async () => {
const imageBuffer = await createImageBuffer();
const readS3Object = vi.fn<S3Utils['readS3Object']>(async (_bucket, key) => {
if (key === 'avatars/42/abc123') {
throw new HTTPException(404);
}
if (key === 'avatars/42/abc123.webp') {
return {
data: imageBuffer,
size: imageBuffer.length,
contentType: 'image/png',
lastModified: undefined,
};
}
throw new Error(`Unexpected key: ${key}`);
});
const app = createImageApp({readS3Object, imageBuffer});
const response = await app.request('/avatars/42/abc123.webp?size=240');
expect(response.status).toBe(200);
expect(readS3Object).toHaveBeenNthCalledWith(1, 'cdn-bucket', 'avatars/42/abc123');
expect(readS3Object).toHaveBeenNthCalledWith(2, 'cdn-bucket', 'avatars/42/abc123.webp');
});
test('keeps extensionless avatar key lookup as primary', async () => {
const imageBuffer = await createImageBuffer();
const readS3Object = vi.fn<S3Utils['readS3Object']>(async (_bucket, key) => {
expect(key).toBe('avatars/42/abc123');
return {
data: imageBuffer,
size: imageBuffer.length,
contentType: 'image/png',
lastModified: undefined,
};
});
const app = createImageApp({readS3Object, imageBuffer});
const response = await app.request('/avatars/42/abc123.webp?size=240');
expect(response.status).toBe(200);
expect(readS3Object).toHaveBeenCalledTimes(1);
expect(readS3Object).toHaveBeenCalledWith('cdn-bucket', 'avatars/42/abc123');
});
test('falls back to extension-suffixed guild member avatar key', async () => {
const imageBuffer = await createImageBuffer();
const readS3Object = vi.fn<S3Utils['readS3Object']>(async (_bucket, key) => {
if (key === 'guilds/100/users/200/avatars/abc123') {
throw new HTTPException(404);
}
if (key === 'guilds/100/users/200/avatars/abc123.webp') {
return {
data: imageBuffer,
size: imageBuffer.length,
contentType: 'image/png',
lastModified: undefined,
};
}
throw new Error(`Unexpected key: ${key}`);
});
const app = createImageApp({readS3Object, imageBuffer});
const response = await app.request('/guilds/100/users/200/avatars/abc123.webp?size=240');
expect(response.status).toBe(200);
expect(readS3Object).toHaveBeenNthCalledWith(1, 'cdn-bucket', 'guilds/100/users/200/avatars/abc123');
expect(readS3Object).toHaveBeenNthCalledWith(2, 'cdn-bucket', 'guilds/100/users/200/avatars/abc123.webp');
});
});

View File

@@ -0,0 +1,293 @@
/*
* 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 {toBodyData} from '@fluxer/media_proxy/src/lib/BinaryUtils';
import {parseRange, setHeaders} from '@fluxer/media_proxy/src/lib/HttpUtils';
import type {ImageProcessor} from '@fluxer/media_proxy/src/lib/ImageProcessing';
import type {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
import {MEDIA_TYPES} from '@fluxer/media_proxy/src/lib/MediaTypes';
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
import type {S3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
import {ImageParamSchema, ImageQuerySchema} from '@fluxer/media_proxy/src/schemas/ValidationSchemas';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {Context} from 'hono';
import {HTTPException} from 'hono/http-exception';
import sharp from 'sharp';
import * as v from 'valibot';
function stripAnimationPrefix(hash: string) {
return hash.startsWith('a_') ? hash.substring(2) : hash;
}
interface ImageControllerDeps {
coalescer: InMemoryCoalescer;
s3Utils: S3Utils;
mimeTypeUtils: MimeTypeUtils;
imageProcessor: ImageProcessor;
bucketCdn: string;
}
async function readImageSource(params: {
s3Utils: S3Utils;
bucketCdn: string;
s3Key: string;
fallbackS3Key?: string | undefined;
}): Promise<Buffer> {
const {s3Utils, bucketCdn, s3Key, fallbackS3Key} = params;
try {
const {data} = await s3Utils.readS3Object(bucketCdn, s3Key);
assert(data instanceof Buffer);
return data;
} catch (error) {
const isNotFound = error instanceof HTTPException && error.status === 404;
if (!isNotFound || !fallbackS3Key) {
throw error;
}
const {data} = await s3Utils.readS3Object(bucketCdn, fallbackS3Key);
assert(data instanceof Buffer);
return data;
}
}
async function processImageRequest(params: {
coalescer: InMemoryCoalescer;
s3Utils: S3Utils;
mimeTypeUtils: MimeTypeUtils;
imageProcessor: ImageProcessor;
bucketCdn: string;
ctx: Context<HonoEnv>;
cacheKey: string;
s3Key: string;
fallbackS3Key?: string | undefined;
ext: string;
aspectRatio: number;
size: string;
quality: string;
animated: boolean;
}): Promise<Response> {
const {
coalescer,
s3Utils,
mimeTypeUtils,
imageProcessor,
bucketCdn,
ctx,
cacheKey,
s3Key,
fallbackS3Key,
ext,
aspectRatio,
size,
quality,
animated,
} = params;
const result = await coalescer.coalesce(cacheKey, async () => {
const data = await readImageSource({
s3Utils,
bucketCdn,
s3Key,
fallbackS3Key,
});
const metadata = await sharp(data).metadata();
const requestedWidth = Number(size);
const originalAspectRatio = (metadata.width || 1) / (metadata.height || 1);
const effectiveAspectRatio = aspectRatio === 0 ? originalAspectRatio : aspectRatio;
const requestedHeight = Math.floor(requestedWidth / effectiveAspectRatio);
const width = Math.min(requestedWidth, metadata.width || 0);
const height = Math.min(requestedHeight, metadata.height || 0);
const normalizedExt = ext.toLowerCase();
const image = await imageProcessor.processImage({
buffer: data,
width,
height,
format: normalizedExt,
quality,
animated,
});
const mimeType = mimeTypeUtils.getMimeType(Buffer.from(''), `image.${normalizedExt}`) || 'application/octet-stream';
return {data: image, contentType: mimeType};
});
const range = parseRange(ctx.req.header('Range') ?? '', result.data.length);
setHeaders(ctx, result.data.length, result.contentType, range);
const fileData = range ? result.data.subarray(range.start, range.end + 1) : result.data;
return ctx.body(toBodyData(fileData));
}
export function createImageRouteHandler(deps: ImageControllerDeps) {
return async (ctx: Context<HonoEnv>, pathPrefix: string, aspectRatio = 0): Promise<Response> => {
const {id, filename} = v.parse(ImageParamSchema, ctx.req.param());
if (!filename || !id) throw new HTTPException(400);
const {size, quality, animated} = v.parse(ImageQuerySchema, ctx.req.query());
const parts = filename.split('.');
const extPart = parts[1];
if (parts.length !== 2 || !extPart || !MEDIA_TYPES.IMAGE.extensions.includes(extPart)) {
throw new HTTPException(400);
}
const [hash, ext] = parts;
if (!hash || !ext) throw new HTTPException(400);
const normalizedExt = ext.toLowerCase();
const strippedHash = stripAnimationPrefix(hash);
const cacheKey = `${pathPrefix}_${id}_${hash}_${normalizedExt}_${size}_${quality}_${aspectRatio}_${animated}`;
const s3Key = `${pathPrefix}/${id}/${strippedHash}`;
const fallbackS3Key = `${pathPrefix}/${id}/${strippedHash}.${normalizedExt}`;
return processImageRequest({
...deps,
ctx,
cacheKey,
s3Key,
fallbackS3Key,
ext: normalizedExt,
aspectRatio,
size,
quality,
animated,
});
};
}
export function createGuildMemberImageRouteHandler(deps: ImageControllerDeps) {
return async (ctx: Context<HonoEnv>, pathPrefix: string, aspectRatio = 0): Promise<Response> => {
const {guild_id, user_id, filename} = ctx.req.param();
if (!filename || !guild_id || !user_id) throw new HTTPException(400);
const {size, quality, animated} = v.parse(ImageQuerySchema, ctx.req.query());
const parts = filename.split('.');
const extPart = parts[1];
if (parts.length !== 2 || !extPart || !MEDIA_TYPES.IMAGE.extensions.includes(extPart)) {
throw new HTTPException(400);
}
const [hash, ext] = parts;
if (!hash || !ext) throw new HTTPException(400);
const normalizedExt = ext.toLowerCase();
const strippedHash = stripAnimationPrefix(hash);
const cacheKey = `${pathPrefix}_${guild_id}_${user_id}_${hash}_${normalizedExt}_${size}_${quality}_${aspectRatio}_${animated}`;
const s3Key = `guilds/${guild_id}/users/${user_id}/${pathPrefix}/${strippedHash}`;
const fallbackS3Key = `guilds/${guild_id}/users/${user_id}/${pathPrefix}/${strippedHash}.${normalizedExt}`;
return processImageRequest({
...deps,
ctx,
cacheKey,
s3Key,
fallbackS3Key,
ext: normalizedExt,
aspectRatio,
size,
quality,
animated,
});
};
}
async function processSimpleImageRequest(params: {
coalescer: InMemoryCoalescer;
s3Utils: S3Utils;
bucketCdn: string;
ctx: Context<HonoEnv>;
cacheKey: string;
s3Key: string;
aspectRatio: number;
size: string;
quality: string;
animated: boolean;
}): Promise<Response> {
const {coalescer, s3Utils, bucketCdn, ctx, cacheKey, s3Key, aspectRatio, size, quality, animated} = params;
const result = await coalescer.coalesce(cacheKey, async () => {
const {data} = await s3Utils.readS3Object(bucketCdn, s3Key);
assert(data instanceof Buffer);
const metadata = await sharp(data).metadata();
const requestedWidth = Number(size);
const originalAspectRatio = (metadata.width || 1) / (metadata.height || 1);
const effectiveAspectRatio = aspectRatio === 0 ? originalAspectRatio : aspectRatio;
const requestedHeight = Math.floor(requestedWidth / effectiveAspectRatio);
const width = Math.min(requestedWidth, metadata.width || 0);
const height = Math.min(requestedHeight, metadata.height || 0);
const isAnimatedSource = (metadata.pages ?? 0) > 1;
const shouldOutputAnimation = isAnimatedSource && animated;
const image = await sharp(data, {animated: shouldOutputAnimation})
.resize(width, height, {
fit: 'contain',
background: {r: 255, g: 255, b: 255, alpha: 0},
withoutEnlargement: true,
})
.toFormat('webp', {
quality: quality === 'high' ? 80 : quality === 'low' ? 20 : 100,
})
.toBuffer();
return {data: image, contentType: 'image/webp'};
});
const range = parseRange(ctx.req.header('Range') ?? '', result.data.length);
setHeaders(ctx, result.data.length, result.contentType, range);
const fileData = range ? result.data.subarray(range.start, range.end + 1) : result.data;
return ctx.body(toBodyData(fileData));
}
export function createSimpleImageRouteHandler(deps: ImageControllerDeps) {
return async (ctx: Context<HonoEnv>, pathPrefix: string, aspectRatio = 0): Promise<Response> => {
const {id} = ctx.req.param();
if (!id) throw new HTTPException(400);
const {size, quality, animated} = v.parse(ImageQuerySchema, ctx.req.query());
const parts = id.split('.');
const extPart = parts[1];
if (parts.length !== 2 || !extPart || !MEDIA_TYPES.IMAGE.extensions.includes(extPart)) {
throw new HTTPException(400);
}
const [filename, ext] = parts;
if (!filename || !ext) throw new HTTPException(400);
const normalizedExt = ext.toLowerCase();
const cacheKey = `${pathPrefix}_${filename}_${normalizedExt}_${size}_${quality}_${aspectRatio}_${animated}`;
const s3Key = `${pathPrefix}/${filename}`;
return processSimpleImageRequest({
coalescer: deps.coalescer,
s3Utils: deps.s3Utils,
bucketCdn: deps.bucketCdn,
ctx,
cacheKey,
s3Key,
aspectRatio,
size,
quality,
animated,
});
};
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {IMetadataService, MetadataRequest} from '@fluxer/media_proxy/src/types/MediaProxyServices';
import {MetadataRequest as MetadataRequestSchema} from '@fluxer/schema/src/domains/media_proxy/MediaProxySchemas';
import type {Context} from 'hono';
import {HTTPException} from 'hono/http-exception';
interface MetadataControllerDeps {
metadataService: IMetadataService;
logger: LoggerInterface;
}
export function createMetadataHandler(deps: MetadataControllerDeps) {
const {metadataService, logger} = deps;
return async (ctx: Context<HonoEnv>) => {
try {
const requestJson = await ctx.req.json<MetadataRequest>();
const request: MetadataRequest = MetadataRequestSchema.parse(requestJson);
const result = await metadataService.getMetadata(request);
return ctx.json(result);
} catch (error) {
if (error instanceof HTTPException) throw error;
logger.error({error}, 'Failed to process metadata request');
throw new HTTPException(400, {message: error instanceof Error ? error.message : 'Failed to process metadata'});
}
};
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import assert from 'node:assert/strict';
import {toBodyData} from '@fluxer/media_proxy/src/lib/BinaryUtils';
import {setHeaders} from '@fluxer/media_proxy/src/lib/HttpUtils';
import type {S3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {Context} from 'hono';
import {HTTPException} from 'hono/http-exception';
interface StaticProxyControllerDeps {
s3Utils: S3Utils;
bucketStatic?: string | undefined;
}
export function createStaticProxyHandler(deps: StaticProxyControllerDeps) {
const {s3Utils, bucketStatic} = deps;
const {readS3Object} = s3Utils;
return async (ctx: Context<HonoEnv>): Promise<Response> => {
const bucket = bucketStatic;
const path = ctx.req.path;
if (!bucket || path === '/') {
return ctx.text('Not Found', 404);
}
const key = path.replace(/^\/+/, '');
try {
const {data, size, contentType, lastModified} = await readS3Object(bucket, key);
assert(Buffer.isBuffer(data));
setHeaders(ctx, size, contentType, null, lastModified);
return ctx.body(toBodyData(data));
} catch (error) {
if (error instanceof HTTPException) {
throw error;
}
return ctx.text('Not Found', 404);
}
};
}

View File

@@ -0,0 +1,120 @@
/*
* 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 {PassThrough} from 'node:stream';
import {createStickerRouteHandler} from '@fluxer/media_proxy/src/controllers/StickerController';
import {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
import type {S3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import {Hono} from 'hono';
import sharp from 'sharp';
import {describe, expect, test, vi} from 'vitest';
async function createStickerBuffer(): Promise<Buffer<ArrayBuffer>> {
const sourceBuffer = await sharp({
create: {
width: 8,
height: 8,
channels: 4,
background: {r: 255, g: 0, b: 0, alpha: 1},
},
})
.webp()
.toBuffer();
return Buffer.from(sourceBuffer);
}
function createMockS3Utils(stickerBuffer: Buffer<ArrayBuffer>, readS3Object: S3Utils['readS3Object']): S3Utils {
return {
headS3Object: async () => ({
contentLength: stickerBuffer.length,
contentType: 'image/webp',
lastModified: undefined,
}),
readS3Object,
streamS3Object: async () => {
const stream = new PassThrough();
stream.end(stickerBuffer);
return {
stream,
size: stickerBuffer.length,
contentType: 'image/webp',
lastModified: undefined,
};
},
};
}
function createStickerApp(params: {
readS3Object: S3Utils['readS3Object'];
stickerBuffer: Buffer<ArrayBuffer>;
}): Hono<HonoEnv> {
const {readS3Object, stickerBuffer} = params;
const app = new Hono<HonoEnv>();
app.get(
'/stickers/:id',
createStickerRouteHandler({
coalescer: new InMemoryCoalescer(),
s3Utils: createMockS3Utils(stickerBuffer, readS3Object),
bucketCdn: 'cdn-bucket',
}),
);
return app;
}
describe('sticker controller', () => {
test('strips extension before S3 lookup', async () => {
const stickerBuffer = await createStickerBuffer();
const readS3Object = vi.fn<S3Utils['readS3Object']>(async (_bucket, key) => {
expect(key).toBe('stickers/1471166588233970012');
return {
data: stickerBuffer,
size: stickerBuffer.length,
contentType: 'image/webp',
lastModified: undefined,
};
});
const app = createStickerApp({readS3Object, stickerBuffer});
const response = await app.request('/stickers/1471166588233970012.webp?size=320&quality=lossless&animated=true');
expect(response.status).toBe(200);
expect(readS3Object).toHaveBeenCalledWith('cdn-bucket', 'stickers/1471166588233970012');
});
test('keeps legacy extensionless sticker lookup working', async () => {
const stickerBuffer = await createStickerBuffer();
const readS3Object = vi.fn<S3Utils['readS3Object']>(async (_bucket, key) => {
expect(key).toBe('stickers/1471166588233970012');
return {
data: stickerBuffer,
size: stickerBuffer.length,
contentType: 'image/webp',
lastModified: undefined,
};
});
const app = createStickerApp({readS3Object, stickerBuffer});
const response = await app.request('/stickers/1471166588233970012?size=320&quality=lossless&animated=false');
expect(response.status).toBe(200);
expect(readS3Object).toHaveBeenCalledWith('cdn-bucket', 'stickers/1471166588233970012');
});
});

View File

@@ -0,0 +1,113 @@
/*
* 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 {toBodyData} from '@fluxer/media_proxy/src/lib/BinaryUtils';
import {parseRange, setHeaders} from '@fluxer/media_proxy/src/lib/HttpUtils';
import type {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
import type {S3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
import {ImageQuerySchema} from '@fluxer/media_proxy/src/schemas/ValidationSchemas';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {Context} from 'hono';
import {HTTPException} from 'hono/http-exception';
import sharp from 'sharp';
import * as v from 'valibot';
interface StickerControllerDeps {
coalescer: InMemoryCoalescer;
s3Utils: S3Utils;
bucketCdn: string;
}
function normalizeStickerObjectId(id: string): string {
const firstDot = id.indexOf('.');
return firstDot === -1 ? id : id.slice(0, firstDot);
}
async function processStickerRequest(params: {
coalescer: InMemoryCoalescer;
s3Utils: S3Utils;
bucketCdn: string;
ctx: Context<HonoEnv>;
cacheKey: string;
s3Key: string;
size: string;
quality: string;
animated: boolean;
}): Promise<Response> {
const {coalescer, s3Utils, bucketCdn, ctx, cacheKey, s3Key, size, quality, animated} = params;
const result = await coalescer.coalesce(cacheKey, async () => {
const {data} = await s3Utils.readS3Object(bucketCdn, s3Key);
assert(data instanceof Buffer);
const metadata = await sharp(data).metadata();
const requestedSize = Number(size);
const width = Math.min(requestedSize, metadata.width || 0);
const height = Math.min(requestedSize, metadata.height || 0);
const isAnimatedSource = (metadata.pages ?? 0) > 1;
const shouldOutputAnimation = isAnimatedSource && animated;
const image = await sharp(data, {animated: shouldOutputAnimation})
.resize(width, height, {
fit: 'contain',
background: {r: 255, g: 255, b: 255, alpha: 0},
withoutEnlargement: true,
})
.toFormat('webp', {
quality: quality === 'high' ? 80 : quality === 'low' ? 20 : 100,
})
.toBuffer();
return {data: image, contentType: 'image/webp'};
});
const range = parseRange(ctx.req.header('Range') ?? '', result.data.length);
setHeaders(ctx, result.data.length, result.contentType, range);
const fileData = range ? result.data.subarray(range.start, range.end + 1) : result.data;
return ctx.body(toBodyData(fileData));
}
export function createStickerRouteHandler(deps: StickerControllerDeps) {
return async (ctx: Context<HonoEnv>): Promise<Response> => {
const {id} = ctx.req.param();
const stickerObjectId = normalizeStickerObjectId(id);
if (!stickerObjectId) {
throw new HTTPException(400);
}
const {size, quality, animated} = v.parse(ImageQuerySchema, ctx.req.query());
const cacheKey = `stickers_${stickerObjectId}_${size}_${quality}_${animated}`;
const s3Key = `stickers/${stickerObjectId}`;
return processStickerRequest({
...deps,
ctx,
cacheKey,
s3Key,
size,
quality,
animated,
});
};
}

View File

@@ -0,0 +1,87 @@
/*
* 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 {PassThrough} from 'node:stream';
import {toBodyData, toWebReadableStream} from '@fluxer/media_proxy/src/lib/BinaryUtils';
import type {S3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {Context} from 'hono';
const THEME_ID_PATTERN = /^[a-f0-9]{16}$/;
interface ThemeControllerDeps {
s3Utils: S3Utils;
bucketCdn: string;
}
export function createThemeHeadHandler(deps: ThemeControllerDeps) {
const {s3Utils, bucketCdn} = deps;
return async (ctx: Context<HonoEnv>): Promise<Response> => {
const filename = ctx.req.param('id.css');
const themeId = filename?.replace(/\.css$/, '');
if (!themeId || !THEME_ID_PATTERN.test(themeId)) {
return ctx.text('Not found', {status: 404});
}
const {contentLength, lastModified} = await s3Utils.headS3Object(bucketCdn, `themes/${themeId}.css`);
ctx.header('Content-Type', 'text/css; charset=utf-8');
ctx.header('Cache-Control', 'public, max-age=31536000, immutable');
ctx.header('Access-Control-Allow-Origin', '*');
ctx.header('Content-Length', contentLength.toString());
if (lastModified) {
ctx.header('Last-Modified', lastModified.toUTCString());
}
return ctx.body(null);
};
}
export function createThemeHandler(deps: ThemeControllerDeps) {
const {s3Utils, bucketCdn} = deps;
return async (ctx: Context<HonoEnv>): Promise<Response> => {
const filename = ctx.req.param('id.css');
const themeId = filename?.replace(/\.css$/, '');
if (!themeId || !THEME_ID_PATTERN.test(themeId)) {
return ctx.text('Not found', {status: 404});
}
const {data, lastModified} = await s3Utils.readS3Object(bucketCdn, `themes/${themeId}.css`);
ctx.header('Content-Type', 'text/css; charset=utf-8');
ctx.header('Cache-Control', 'public, max-age=31536000, immutable');
ctx.header('Access-Control-Allow-Origin', '*');
if (lastModified) {
ctx.header('Last-Modified', new Date(lastModified).toUTCString());
}
if (data instanceof PassThrough) {
return ctx.body(toWebReadableStream(data));
} else {
ctx.header('Content-Length', data.length.toString());
return ctx.body(toBodyData(data));
}
};
}

View File

@@ -0,0 +1,93 @@
/*
* 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/promises';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import {toBodyData} from '@fluxer/media_proxy/src/lib/BinaryUtils';
import type {FFmpegUtilsType} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
import type {S3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {Context} from 'hono';
import {HTTPException} from 'hono/http-exception';
import sharp from 'sharp';
import {temporaryFile} from 'tempy';
import * as v from 'valibot';
const ThumbnailRequestSchema = v.object({
type: v.literal('upload'),
upload_filename: v.string(),
});
interface ThumbnailControllerDeps {
s3Utils: S3Utils;
mimeTypeUtils: MimeTypeUtils;
ffmpegUtils: FFmpegUtilsType;
logger: LoggerInterface;
bucketUploads: string;
}
export function createThumbnailHandler(deps: ThumbnailControllerDeps) {
const {s3Utils, mimeTypeUtils, ffmpegUtils, logger, bucketUploads} = deps;
const {readS3Object} = s3Utils;
const {getMimeType, getMediaCategory} = mimeTypeUtils;
const {createThumbnail} = ffmpegUtils;
return async (ctx: Context<HonoEnv>): Promise<Response> => {
try {
const body = await ctx.req.json();
const {upload_filename} = v.parse(ThumbnailRequestSchema, body);
const {data} = await readS3Object(bucketUploads, upload_filename);
assert(data instanceof Buffer);
const mimeType = getMimeType(data, upload_filename);
if (!mimeType) {
throw new HTTPException(400, {message: 'Unable to determine file type'});
}
const mediaType = getMediaCategory(mimeType);
if (mediaType !== 'video') {
throw new HTTPException(400, {message: 'Not a video file'});
}
const ext = mimeType.split('/')[1] || 'mp4';
const tempVideoPath = temporaryFile({extension: ext});
ctx.get('tempFiles').push(tempVideoPath);
await fs.writeFile(tempVideoPath, data);
const thumbnailPath = await createThumbnail(tempVideoPath);
ctx.get('tempFiles').push(thumbnailPath);
const thumbnailData = await fs.readFile(thumbnailPath);
const processedThumbnail = await sharp(thumbnailData).jpeg({quality: 80}).toBuffer();
return ctx.body(toBodyData(processedThumbnail), {
headers: {
'Content-Type': 'image/jpeg',
},
});
} catch (error) {
logger.error({error}, 'Failed to generate thumbnail');
throw new HTTPException(404);
}
};
}

View File

@@ -0,0 +1,42 @@
/*
* 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 {Readable, type Stream} from 'node:stream';
import type {ReadableStream as WebReadableStream} from 'node:stream/web';
type BinaryLike = ArrayBufferView | ArrayBuffer;
export function toBodyData(value: BinaryLike): Uint8Array<ArrayBuffer> {
if (value instanceof ArrayBuffer) {
return new Uint8Array(value);
}
if (value.buffer instanceof ArrayBuffer) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
const copyBuffer = new ArrayBuffer(value.byteLength);
const view = new Uint8Array(copyBuffer);
view.set(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
return new Uint8Array(copyBuffer);
}
export function toWebReadableStream(stream: Stream): WebReadableStream<Uint8Array> {
return Readable.toWeb(stream as Readable);
}

View File

@@ -0,0 +1,298 @@
/*
* 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 {isIP} from 'node:net';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
const CLOUDFLARE_IPV4_URL = 'https://www.cloudflare.com/ips-v4';
const CLOUDFLARE_IPV6_URL = 'https://www.cloudflare.com/ips-v6';
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
type IPFamily = 4 | 6;
interface ParsedIP {
family: IPFamily;
bytes: Buffer;
}
interface CidrEntry {
family: IPFamily;
network: Buffer;
prefixLength: number;
}
type FetchFunction = (
url: string,
method: string,
) => Promise<{status: number; stream: ReadableStream<Uint8Array> | null}>;
export class CloudflareEdgeIPService {
private cidrs: Array<CidrEntry> = [];
private refreshTimer?: NodeJS.Timeout | undefined;
private logger: LoggerInterface;
private fetchFn: FetchFunction;
constructor(logger: LoggerInterface, fetchFn: FetchFunction) {
this.logger = logger;
this.fetchFn = fetchFn;
}
async initialize(): Promise<void> {
await this.refreshNow();
this.refreshTimer = setInterval(() => {
this.refreshNow().catch((error) => {
this.logger.error({error}, 'Failed to refresh Cloudflare edge IP ranges; keeping existing ranges');
});
}, REFRESH_INTERVAL_MS);
this.refreshTimer.unref();
}
shutdown(): void {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = undefined;
}
}
isFromCloudflareEdge(ip: string | undefined | null): boolean {
if (!ip) return false;
if (this.cidrs.length === 0) return false;
const parsed = parseIP(ip);
if (!parsed) return false;
for (const cidr of this.cidrs) {
if (cidr.family !== parsed.family) continue;
if (ipInCidr(parsed, cidr)) return true;
}
return false;
}
private async refreshNow(): Promise<void> {
const [v4Ranges, v6Ranges] = await Promise.all([
this.fetchRanges(CLOUDFLARE_IPV4_URL),
this.fetchRanges(CLOUDFLARE_IPV6_URL),
]);
const nextCidrs: Array<CidrEntry> = [];
for (const range of [...v4Ranges, ...v6Ranges]) {
const parsed = parseCIDR(range);
if (!parsed) continue;
nextCidrs.push(parsed);
}
if (nextCidrs.length === 0) {
throw new Error('Cloudflare edge IP list refresh returned no valid ranges');
}
this.cidrs = nextCidrs;
this.logger.info({count: this.cidrs.length}, 'Refreshed Cloudflare edge IP ranges');
}
private async fetchRanges(url: string): Promise<Array<string>> {
const response = await this.fetchFn(url, 'GET');
if (response.status !== 200) {
throw new Error(`Failed to download Cloudflare edge IPs from ${url} (status ${response.status})`);
}
const text = await CloudflareEdgeIPService.streamToString(response.stream);
return text
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
}
private static async streamToString(stream: ReadableStream<Uint8Array> | null): Promise<string> {
if (!stream) {
return '';
}
return new Response(stream).text();
}
}
function parseIP(ip: string): ParsedIP | null {
const ipWithoutZone = ip.split('%', 1)[0]?.trim();
if (!ipWithoutZone) return null;
const family = isIP(ipWithoutZone);
if (!family) return null;
if (family === 6 && ipWithoutZone.includes('.')) {
const lastColon = ipWithoutZone.lastIndexOf(':');
const ipv4Part = ipWithoutZone.slice(lastColon + 1);
const bytes = parseIPv4(ipv4Part);
if (!bytes) return null;
return {family: 4, bytes};
}
if (family === 4) {
const bytes = parseIPv4(ipWithoutZone);
if (!bytes) return null;
return {family: 4, bytes};
}
const bytes = parseIPv6(ipWithoutZone);
if (!bytes) return null;
return {family: 6, bytes};
}
function parseIPv4(ip: string): Buffer | null {
const parts = ip.split('.');
if (parts.length !== 4) return null;
const bytes = Buffer.alloc(4);
for (let i = 0; i < 4; i++) {
const part = parts[i];
if (!part || !/^\d+$/.test(part)) return null;
const octet = Number(part);
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
bytes[i] = octet;
}
return bytes;
}
function parseIPv6(ip: string): Buffer | null {
const addr = ip.trim();
const hasDoubleColon = addr.includes('::');
let headPart = '';
let tailPart = '';
if (hasDoubleColon) {
const parts = addr.split('::');
if (parts.length !== 2) return null;
headPart = parts[0] ?? '';
tailPart = parts[1] ?? '';
} else {
headPart = addr;
tailPart = '';
}
const headGroups = headPart ? headPart.split(':').filter((g) => g.length > 0) : [];
const tailGroups = tailPart ? tailPart.split(':').filter((g) => g.length > 0) : [];
if (!hasDoubleColon && headGroups.length !== 8) return null;
const totalGroups = headGroups.length + tailGroups.length;
if (totalGroups > 8) return null;
const zerosToInsert = 8 - totalGroups;
const groups: Array<number> = [];
for (const g of headGroups) {
const value = parseInt(g, 16);
if (!Number.isFinite(value) || value < 0 || value > 0xffff) return null;
groups.push(value);
}
for (let i = 0; i < zerosToInsert; i++) {
groups.push(0);
}
for (const g of tailGroups) {
const value = parseInt(g, 16);
if (!Number.isFinite(value) || value < 0 || value > 0xffff) return null;
groups.push(value);
}
if (groups.length !== 8) return null;
const bytes = Buffer.alloc(16);
for (let i = 0; i < 8; i++) {
const group = groups[i] ?? 0;
bytes[i * 2] = (group >> 8) & 0xff;
bytes[i * 2 + 1] = group & 0xff;
}
return bytes;
}
function parseCIDR(cidr: string): CidrEntry | null {
const trimmed = cidr.trim();
if (!trimmed) return null;
const slashIdx = trimmed.indexOf('/');
if (slashIdx === -1) return null;
const ipPart = trimmed.slice(0, slashIdx).trim();
const prefixPart = trimmed.slice(slashIdx + 1).trim();
if (!ipPart || !prefixPart) return null;
const prefixLength = Number(prefixPart);
if (!Number.isInteger(prefixLength) || prefixLength < 0) return null;
const parsedIP = parseIP(ipPart);
if (!parsedIP) return null;
const maxPrefix = parsedIP.family === 4 ? 32 : 128;
if (prefixLength > maxPrefix) return null;
const network = Buffer.from(parsedIP.bytes);
const fullBytes = Math.floor(prefixLength / 8);
const remainingBits = prefixLength % 8;
if (fullBytes < network.length) {
if (remainingBits !== 0 && fullBytes < network.length) {
const mask = (0xff << (8 - remainingBits)) & 0xff;
const currentByte = network[fullBytes];
if (currentByte !== undefined) {
network[fullBytes] = currentByte & mask;
}
for (let i = fullBytes + 1; i < network.length; i++) {
network[i] = 0;
}
} else {
for (let i = fullBytes; i < network.length; i++) {
network[i] = 0;
}
}
}
return {
family: parsedIP.family,
network,
prefixLength,
};
}
function ipInCidr(ip: ParsedIP, cidr: CidrEntry): boolean {
if (ip.family !== cidr.family) return false;
const prefixLength = cidr.prefixLength;
const bytesToCheck = Math.floor(prefixLength / 8);
const remainingBits = prefixLength % 8;
for (let i = 0; i < bytesToCheck; i++) {
if (ip.bytes[i] !== cidr.network[i]) return false;
}
if (remainingBits > 0 && bytesToCheck < ip.bytes.length) {
const mask = (0xff << (8 - remainingBits)) & 0xff;
const idx = bytesToCheck;
const ipByte = ip.bytes[idx];
const cidrByte = cidr.network[idx];
if (ipByte !== undefined && cidrByte !== undefined && (ipByte & mask) !== (cidrByte & mask)) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,101 @@
/*
* 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 type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import {type FFprobeStream, ffprobe} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
import {MEDIA_TYPES} from '@fluxer/media_proxy/src/lib/MediaTypes';
import {temporaryFile} from 'tempy';
interface AudioStream extends FFprobeStream {
codec_type: 'audio';
}
function matchesCodecPattern(codec: string, patterns: Set<string>): boolean {
if (!codec) return false;
const lowerCodec = codec.toLowerCase();
return (
patterns.has(lowerCodec) ||
Array.from(patterns).some((pattern) => {
if (pattern.includes('*')) {
return new RegExp(`^${pattern.replace('*', '.*')}$`).test(lowerCodec);
}
return false;
})
);
}
function isProRes4444(codec: string): boolean {
if (!codec) return false;
const lowercaseCodec = codec.toLowerCase();
return (
matchesCodecPattern(lowercaseCodec, MEDIA_TYPES.VIDEO.bannedCodecs) ||
(lowercaseCodec.includes('prores') && lowercaseCodec.includes('4444'))
);
}
export function createCodecValidator(logger: LoggerInterface) {
const validateCodecs = async (buffer: Buffer, filename: string): Promise<boolean> => {
const ext = filename.split('.').pop()?.toLowerCase();
if (!ext) return false;
const tempPath = temporaryFile({extension: ext});
try {
await fs.writeFile(tempPath, buffer);
const probeData = await ffprobe(tempPath);
if (filename.toLowerCase().endsWith('.ogg')) {
const hasVideo = probeData.streams?.some((stream) => stream.codec_type === 'video');
if (hasVideo) return false;
const audioStream = probeData.streams?.find((stream): stream is AudioStream => stream.codec_type === 'audio');
return Boolean(audioStream?.codec_name && ['opus', 'vorbis'].includes(audioStream.codec_name));
}
const validateStream = (stream: FFprobeStream, type: 'video' | 'audio') => {
const codec = stream.codec_name || '';
if (type === 'video') {
if (isProRes4444(codec)) return false;
return matchesCodecPattern(codec, MEDIA_TYPES.VIDEO.codecs);
}
return matchesCodecPattern(codec, MEDIA_TYPES.AUDIO.codecs);
};
for (const stream of probeData.streams || []) {
if (stream.codec_type === 'video' || stream.codec_type === 'audio') {
if (!validateStream(stream, stream.codec_type)) {
logger.debug({filename, codec: stream.codec_name ?? 'unknown'}, `Unsupported ${stream.codec_type} codec`);
return false;
}
}
}
return true;
} catch (err) {
logger.error({error: err, filename}, 'Failed to validate media codecs');
return false;
} finally {
await fs.unlink(tempPath).catch(() => {});
}
};
return {validateCodecs};
}
export type CodecValidator = ReturnType<typeof createCodecValidator>;

View File

@@ -0,0 +1,307 @@
/*
* 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/promises';
import {promisify} from 'node:util';
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
import {temporaryFile} from 'tempy';
const execFileAsync = promisify(execFile);
import {
DEFAULT_FFPROBE_TIMEOUT_MS,
DEFAULT_FRAME_EXTRACTION_TIMEOUT_MS,
DEFAULT_THUMBNAIL_TIMEOUT_MS,
} from '@fluxer/constants/src/Timeouts';
export class FFmpegTimeoutError extends Error {
readonly operation: string;
readonly timeoutMs: number;
constructor(operation: string, timeoutMs: number) {
super(`FFmpeg operation '${operation}' timed out after ${timeoutMs}ms`);
this.name = 'FFmpegTimeoutError';
this.operation = operation;
this.timeoutMs = timeoutMs;
}
}
function isTimeoutError(error: unknown): boolean {
if (error instanceof Error && 'killed' in error) {
return (error as {killed: boolean}).killed === true;
}
return false;
}
export interface FFprobeStream {
codec_name?: string;
codec_type?: string;
}
interface FFprobeFormat {
format_name?: string;
duration?: string;
size?: string;
}
interface FFprobeResult {
streams?: Array<FFprobeStream>;
format?: FFprobeFormat;
}
function parseProbeOutput(stdout: string): FFprobeResult {
const parsed = JSON.parse(stdout) as unknown;
if (!parsed || typeof parsed !== 'object') {
throw new Error('Invalid ffprobe output');
}
return parsed as FFprobeResult;
}
export async function ffprobe(path: string, timeoutMs = DEFAULT_FFPROBE_TIMEOUT_MS): Promise<FFprobeResult> {
try {
const {stdout} = await execFileAsync(
'ffprobe',
['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', path],
{timeout: timeoutMs},
);
return parseProbeOutput(stdout);
} catch (error) {
if (isTimeoutError(error)) {
throw new FFmpegTimeoutError('ffprobe', timeoutMs);
}
throw error;
}
}
export async function hasVideoStream(path: string): Promise<boolean> {
const probeResult = await ffprobe(path);
return probeResult.streams?.some((stream) => stream.codec_type === 'video') ?? false;
}
export function createFFmpegUtils(options?: {
metrics?: MetricsInterface | undefined;
tracing?: TracingInterface | undefined;
}) {
const {metrics, tracing} = options ?? {};
const createThumbnail = async (videoPath: string, timeoutMs = DEFAULT_THUMBNAIL_TIMEOUT_MS): Promise<string> => {
const fn = async () => {
const startTime = Date.now();
const hasVideo = await hasVideoStream(videoPath);
if (!hasVideo) {
throw new Error('File does not contain a video stream');
}
const thumbnailPath = temporaryFile({extension: 'jpg'});
try {
await execFileAsync('ffmpeg', ['-i', videoPath, '-vf', 'select=eq(n\\,0)', '-vframes', '1', thumbnailPath], {
timeout: timeoutMs,
});
} catch (error) {
if (isTimeoutError(error)) {
throw new FFmpegTimeoutError('thumbnail', timeoutMs);
}
throw error;
}
const duration = Date.now() - startTime;
metrics?.histogram({
name: 'media_proxy.thumbnail.latency',
dimensions: {},
valueMs: duration,
});
return thumbnailPath;
};
if (tracing) {
return tracing.withSpan(
{
name: 'video.thumbnail.create',
attributes: {
'video.path': videoPath,
},
},
fn,
);
}
return fn();
};
return {createThumbnail};
}
export type FFmpegUtilsType = ReturnType<typeof createFFmpegUtils>;
export interface MediaProbeInfo {
durationSeconds: number | null;
hasVideoStream: boolean;
}
export async function getMediaProbeInfo(path: string): Promise<MediaProbeInfo> {
const probeResult = await ffprobe(path);
const durationSeconds =
probeResult.format?.duration && Number.isFinite(Number.parseFloat(probeResult.format.duration))
? Number.parseFloat(probeResult.format.duration)
: null;
const hasVideo = probeResult.streams?.some((stream) => stream.codec_type === 'video') ?? false;
return {
durationSeconds,
hasVideoStream: hasVideo,
};
}
function clampToDuration(value: number, duration: number | null): number {
if (!Number.isFinite(duration as number) || (duration ?? 0) <= 0) {
return Math.max(0, value);
}
const maxValue = Math.max(0, (duration as number) - 0.001);
if (maxValue <= 0) {
return 0;
}
return Math.max(0, Math.min(value, maxValue));
}
function applyJitter(value: number, duration: number | null, jitterPercent: number): number {
const jitterRadius = Math.max(0.05, Math.abs(value) * jitterPercent);
const jittered = value + (Math.random() * 2 - 1) * jitterRadius;
return clampToDuration(jittered, duration);
}
export function computeFrameSampleTimestamps(durationSeconds: number | null, jitterPercent = 0.1): Array<number> {
const actualDuration =
Number.isFinite(durationSeconds as number) && (durationSeconds as number) > 0 ? (durationSeconds as number) : null;
const fallbackDuration = actualDuration ?? 1;
const startBase = clampToDuration(Math.min(2, Math.max(1, fallbackDuration * 0.1 + 0.5)), actualDuration);
const middleBase = clampToDuration(fallbackDuration / 2, actualDuration);
const endCandidate = fallbackDuration > 2 ? fallbackDuration - 1 : fallbackDuration * 0.95;
const minEnd = startBase + 0.5;
const endBase = clampToDuration(Math.max(minEnd, endCandidate), actualDuration);
return [
applyJitter(startBase, actualDuration, jitterPercent),
applyJitter(middleBase, actualDuration, jitterPercent),
applyJitter(endBase, actualDuration, jitterPercent),
];
}
function normalizeTimestamp(value: number): number {
return Number.isFinite(value) && value >= 0 ? value : 0;
}
export function createFrameExtractor(options?: {tracing?: TracingInterface | undefined}) {
const {tracing} = options ?? {};
const extractFrameAt = async (
mediaPath: string,
timestampSeconds: number,
timeoutMs = DEFAULT_FRAME_EXTRACTION_TIMEOUT_MS,
): Promise<string> => {
const fn = async () => {
const timestamp = normalizeTimestamp(timestampSeconds);
const framePath = temporaryFile({extension: 'jpg'});
try {
await execFileAsync(
'ffmpeg',
[
'-hide_banner',
'-loglevel',
'error',
'-ss',
timestamp.toFixed(3),
'-i',
mediaPath,
'-frames:v',
'1',
'-q:v',
'2',
'-y',
framePath,
],
{timeout: timeoutMs},
);
} catch (error) {
if (isTimeoutError(error)) {
throw new FFmpegTimeoutError('frame_extraction', timeoutMs);
}
throw error;
}
try {
await fs.access(framePath);
} catch {
throw new Error(`Frame extraction produced no output at timestamp ${timestamp}`);
}
return framePath;
};
if (tracing) {
return tracing.withSpan(
{
name: 'video.frame.extract',
attributes: {
'video.path': mediaPath,
'video.timestamp': String(timestampSeconds),
},
},
fn,
);
}
return fn();
};
const extractFramesAtTimes = async (mediaPath: string, timestamps: Array<number>): Promise<Array<string>> => {
if (timestamps.length === 0) {
timestamps = [0];
}
const extractionPromises = timestamps.map((timestamp) => {
const clampedTimestamp = Math.max(0, timestamp);
return extractFrameAt(mediaPath, clampedTimestamp);
});
const results = await Promise.allSettled(extractionPromises);
const frames: Array<string> = [];
for (const result of results) {
if (result.status === 'fulfilled') {
frames.push(result.value);
}
}
if (frames.length === 0) {
const firstError = results.find((r) => r.status === 'rejected');
if (firstError && firstError.status === 'rejected') {
throw firstError.reason instanceof Error ? firstError.reason : new Error(String(firstError.reason));
}
throw new Error('No frames could be extracted');
}
return frames;
};
return {extractFrameAt, extractFramesAtTimes};
}
export type FrameExtractor = ReturnType<typeof createFrameExtractor>;

View File

@@ -0,0 +1,67 @@
/*
* 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 {Context} from 'hono';
export function parseRange(rangeHeader: string | null, fileSize: number) {
if (!rangeHeader) return null;
const matches = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (!matches) return null;
const start = matches[1] ? Number.parseInt(matches[1], 10) : 0;
const end = matches[2] ? Number.parseInt(matches[2], 10) : fileSize - 1;
return start >= fileSize || end >= fileSize || start > end ? null : {start, end};
}
export function setHeaders(
ctx: Context,
size: number,
contentType: string,
range: {start: number; end: number} | null,
lastModified?: Date,
) {
const isStreamableMedia = contentType.startsWith('video/') || contentType.startsWith('audio/');
const headers = {
'Accept-Ranges': 'bytes',
'Access-Control-Allow-Origin': '*',
'Cache-Control': isStreamableMedia
? 'public, max-age=31536000, no-transform, immutable'
: 'public, max-age=31536000',
'Content-Type': contentType,
Date: new Date().toUTCString(),
Expires: new Date(Date.now() + 31536000000).toUTCString(),
'Last-Modified': lastModified?.toUTCString() ?? new Date().toUTCString(),
Vary: 'Accept-Encoding, Range',
};
Object.entries(headers).forEach(([k, v]) => {
ctx.header(k, v);
});
if (range) {
const length = range.end - range.start + 1;
ctx.status(206);
ctx.header('Content-Length', length.toString());
ctx.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`);
} else {
ctx.header('Content-Length', size.toString());
}
}

View File

@@ -0,0 +1,141 @@
/*
* 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 {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
import sharp from 'sharp';
import {rgbaToThumbHash} from 'thumbhash';
export async function generatePlaceholder(imageBuffer: Buffer): Promise<string> {
try {
const metadata = await sharp(imageBuffer).metadata();
const width = metadata.width ?? 1;
const height = metadata.height ?? 1;
if (width < 3 || height < 3) {
return '';
}
let pipeline = sharp(imageBuffer);
if (width > 10 && height > 10) {
const blurSigma = Math.min(10, Math.floor(Math.min(width, height) / 10));
if (blurSigma >= 0.3) {
pipeline = pipeline.blur(blurSigma);
}
}
const {data, info} = await pipeline
.resize(100, 100, {fit: 'inside', withoutEnlargement: true})
.ensureAlpha()
.raw()
.toBuffer({resolveWithObject: true});
if (data.length !== info.width * info.height * 4) {
return '';
}
const placeholder = rgbaToThumbHash(info.width, info.height, data);
return Buffer.from(placeholder).toString('base64');
} catch {
return '';
}
}
const ANIMATED_OUTPUT_FORMATS = new Set(['gif', 'webp', 'avif', 'png', 'apng']);
export function createImageProcessor(options?: {
metrics?: MetricsInterface | undefined;
tracing?: TracingInterface | undefined;
}) {
const {metrics, tracing} = options ?? {};
const processImage = async (opts: {
buffer: Buffer;
width: number;
height: number;
format: string;
quality: string;
animated: boolean;
}): Promise<Buffer> => {
const fn = async () => {
const startTime = Date.now();
const metadata = await sharp(opts.buffer).metadata();
const resizeWidth = Math.min(opts.width, metadata.width || 0);
const resizeHeight = Math.min(opts.height, metadata.height || 0);
const targetFormat = opts.format.toLowerCase();
if (!targetFormat) {
throw new Error('Target image format is required');
}
const supportsAnimation = ANIMATED_OUTPUT_FORMATS.has(targetFormat);
const shouldAnimate = opts.animated && supportsAnimation;
const result = await sharp(opts.buffer, {
animated: shouldAnimate,
})
.resize(resizeWidth, resizeHeight, {
fit: 'cover',
withoutEnlargement: true,
})
.toFormat(targetFormat as keyof sharp.FormatEnum, {
quality: opts.quality === 'high' ? 80 : opts.quality === 'low' ? 20 : 100,
})
.toBuffer();
const duration = Date.now() - startTime;
metrics?.histogram({
name: 'media_proxy.transform.latency',
dimensions: {format: targetFormat, quality: opts.quality},
valueMs: duration,
});
metrics?.counter({
name: 'media_proxy.transform.bytes',
dimensions: {format: targetFormat, quality: opts.quality},
value: result.length,
});
return result;
};
if (tracing) {
return tracing.withSpan(
{
name: 'image.transform',
attributes: {
'image.width': opts.width,
'image.height': opts.height,
'image.format': opts.format,
'image.quality': opts.quality,
'image.animated': String(opts.animated),
},
},
fn,
);
}
return fn();
};
return {processImage};
}
export type ImageProcessor = ReturnType<typeof createImageProcessor>;

View File

@@ -0,0 +1,64 @@
/*
* 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 {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
export class InMemoryCoalescer {
private pending = new Map<string, Promise<unknown>>();
private metrics: MetricsInterface | undefined;
private tracing: TracingInterface | undefined;
constructor(options?: {metrics?: MetricsInterface | undefined; tracing?: TracingInterface | undefined}) {
this.metrics = options?.metrics;
this.tracing = options?.tracing;
}
async coalesce<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = this.pending.get(key) as Promise<T> | undefined;
if (existing) {
this.metrics?.counter({name: 'media_proxy.cache.hit'});
this.tracing?.addSpanEvent('cache.hit', {cache_key: key});
return existing;
}
this.metrics?.counter({name: 'media_proxy.cache.miss'});
this.tracing?.addSpanEvent('cache.miss', {cache_key: key});
const promise = (async () => {
try {
if (this.tracing) {
return await this.tracing.withSpan(
{
name: 'cache.compute',
attributes: {cache_key: key},
},
async () => fn(),
);
}
return await fn();
} finally {
this.pending.delete(key);
}
})();
this.pending.set(key, promise);
return promise;
}
}

View File

@@ -0,0 +1,126 @@
/*
* 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 type {FFmpegUtilsType} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
import type {ImageProcessor} from '@fluxer/media_proxy/src/lib/ImageProcessing';
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
import sharp from 'sharp';
import {temporaryFile} from 'tempy';
interface TransformResult {
data: Buffer;
contentType: string;
}
interface ImageTransformOptions {
width?: number;
height?: number;
format?: string;
quality: string;
animated: boolean;
fallbackContentType: string;
}
interface VideoThumbnailTransformOptions {
width?: number;
height?: number;
format: string;
quality: string;
}
interface MediaTransformServiceDeps {
imageProcessor: ImageProcessor;
ffmpegUtils: FFmpegUtilsType;
mimeTypeUtils: MimeTypeUtils;
}
export function createMediaTransformService(deps: MediaTransformServiceDeps) {
const {imageProcessor, ffmpegUtils, mimeTypeUtils} = deps;
const {processImage} = imageProcessor;
const {createThumbnail} = ffmpegUtils;
const {getMimeType} = mimeTypeUtils;
async function transformImage(buffer: Buffer, options: ImageTransformOptions): Promise<TransformResult> {
const metadata = await sharp(buffer).metadata();
const targetWidth = options.width ? Math.min(options.width, metadata.width || 0) : metadata.width || 0;
const targetHeight = options.height ? Math.min(options.height, metadata.height || 0) : metadata.height || 0;
const outputFormat = (options.format || metadata.format?.toLowerCase() || '').toLowerCase();
const image = await processImage({
buffer,
width: targetWidth,
height: targetHeight,
format: outputFormat,
quality: options.quality,
animated: options.animated,
});
const contentType = options.format
? getMimeType(Buffer.from(''), `image.${options.format}`) || 'application/octet-stream'
: options.fallbackContentType;
return {data: image, contentType};
}
async function transformVideoThumbnail(
buffer: Buffer,
mimeType: string,
options: VideoThumbnailTransformOptions,
): Promise<TransformResult> {
const ext = mimeType.split('/')[1] ?? 'bin';
const tempPath = temporaryFile({extension: ext});
const tempFiles: Array<string> = [tempPath];
try {
await fs.writeFile(tempPath, buffer);
const thumbnailPath = await createThumbnail(tempPath);
tempFiles.push(thumbnailPath);
const thumbnailData = await fs.readFile(thumbnailPath);
const thumbMeta = await sharp(thumbnailData).metadata();
const targetWidth = options.width ? Math.min(options.width, thumbMeta.width || 0) : thumbMeta.width || 0;
const targetHeight = options.height ? Math.min(options.height, thumbMeta.height || 0) : thumbMeta.height || 0;
const processedThumbnail = await processImage({
buffer: thumbnailData,
width: targetWidth,
height: targetHeight,
format: options.format,
quality: options.quality,
animated: false,
});
const contentType = getMimeType(Buffer.from(''), `image.${options.format}`);
if (!contentType) {
throw new Error('Unsupported image format');
}
return {data: processedThumbnail, contentType};
} finally {
await Promise.all(tempFiles.map((f) => fs.unlink(f).catch(() => {})));
}
}
return {transformImage, transformVideoThumbnail};
}
export type MediaTransformService = ReturnType<typeof createMediaTransformService>;

View File

@@ -0,0 +1,91 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export const MEDIA_TYPES = {
IMAGE: {
extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'],
mimes: {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
svg: 'image/svg+xml',
},
},
VIDEO: {
extensions: ['mp4', 'webm', 'mov', 'mkv', 'avi'],
mimes: {
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
mkv: 'video/x-matroska',
avi: 'video/x-msvideo',
},
codecs: new Set([
'h264',
'avc1',
'hevc',
'hev1',
'hvc1',
'h265',
'vp8',
'vp9',
'av1',
'av01',
'theora',
'mpeg4',
'mpeg2video',
'mpeg1video',
'h263',
'prores',
'mjpeg',
'wmv1',
'wmv2',
'wmv3',
'vc1',
'msmpeg4v3',
]),
bannedCodecs: new Set(['prores_4444', 'prores_4444xq', 'apch', 'apcn', 'apcs', 'apco', 'ap4h', 'ap4x']),
},
AUDIO: {
extensions: ['mp3', 'wav', 'flac', 'opus', 'aac', 'm4a', 'ogg'],
mimes: {
mp3: 'audio/mpeg',
wav: 'audio/wav',
flac: 'audio/flac',
opus: 'audio/opus',
aac: 'audio/aac',
m4a: 'audio/mp4',
ogg: 'audio/ogg',
},
codecs: new Set(['aac', 'mp4a', 'mp3', 'opus', 'vorbis', 'flac', 'pcm_s16le', 'pcm_s24le', 'pcm_f32le']),
},
};
export const SUPPORTED_EXTENSIONS = {
...MEDIA_TYPES.IMAGE.mimes,
...MEDIA_TYPES.VIDEO.mimes,
...MEDIA_TYPES.AUDIO.mimes,
};
export const SUPPORTED_MIME_TYPES = new Set(Object.values(SUPPORTED_EXTENSIONS));
export type SupportedExtension = keyof typeof SUPPORTED_EXTENSIONS;

View File

@@ -0,0 +1,170 @@
/*
* 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 type {CodecValidator} from '@fluxer/media_proxy/src/lib/CodecValidation';
import type {FFmpegUtilsType} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
import {ffprobe} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
import {generatePlaceholder} from '@fluxer/media_proxy/src/lib/ImageProcessing';
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
import sharp from 'sharp';
import {temporaryFile} from 'tempy';
interface ImageMetadata {
format: string;
size: number;
width: number;
height: number;
placeholder: string;
animated: boolean;
}
interface VideoMetadata {
format: string;
size: number;
width: number;
height: number;
duration: number;
placeholder: string;
}
interface AudioMetadata {
format: string;
size: number;
duration: number;
}
type MediaMetadata = ImageMetadata | VideoMetadata | AudioMetadata;
export interface MediaValidator {
validateMedia: (buffer: Buffer, filename: string) => Promise<string>;
processMetadata: (mimeType: string, buffer: Buffer) => Promise<MediaMetadata>;
}
export function createMediaValidator(
mimeTypeUtils: MimeTypeUtils,
codecValidator: CodecValidator,
ffmpegUtils: FFmpegUtilsType,
): MediaValidator {
const {getMimeType, generateFilename, getMediaCategory} = mimeTypeUtils;
const {validateCodecs} = codecValidator;
const {createThumbnail} = ffmpegUtils;
async function validateMedia(buffer: Buffer, filename: string): Promise<string> {
const mimeType = getMimeType(buffer, filename);
if (!mimeType) {
throw new Error('Unsupported file format');
}
const mediaType = getMediaCategory(mimeType);
if (!mediaType) {
throw new Error('Invalid media type');
}
if (mediaType !== 'image') {
const validationFilename = filename.includes('.') ? filename : generateFilename(mimeType, filename);
const isValid = await validateCodecs(buffer, validationFilename);
if (!isValid) {
throw new Error('File contains unsupported or non-web-compatible codecs');
}
}
return mimeType;
}
function toNumericField(value: string | number | undefined): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
async function processMetadata(mimeType: string, buffer: Buffer): Promise<MediaMetadata> {
const mediaType = getMediaCategory(mimeType);
if (mediaType === 'image') {
const {
format: rawFormat = '',
size = 0,
width = 0,
height: rawHeight = 0,
pages,
} = await sharp(buffer, {
animated: true,
}).metadata();
const animated = pages ? pages > 1 : false;
const height = animated ? Math.round(rawHeight / pages!) : rawHeight;
if (width > 9500 || height > 9500) {
throw new Error('Image dimensions too large');
}
return {
format: rawFormat.toLowerCase(),
size,
width,
height,
placeholder: await generatePlaceholder(buffer),
animated,
} satisfies ImageMetadata;
}
const ext = mimeType.split('/')[1] ?? 'bin';
const tempPath = temporaryFile({extension: ext});
const tempFiles: Array<string> = [tempPath];
try {
await fs.writeFile(tempPath, buffer);
if (mediaType === 'video') {
const thumbnailPath = await createThumbnail(tempPath);
tempFiles.push(thumbnailPath);
const thumbnailData = await fs.readFile(thumbnailPath);
const {width = 0, height = 0} = await sharp(thumbnailData).metadata();
const probeData = await ffprobe(tempPath);
return {
format: (probeData.format?.format_name || '').toLowerCase(),
size: toNumericField(probeData.format?.size),
width,
height,
duration: Math.ceil(Number(probeData.format?.duration || 0)),
placeholder: await generatePlaceholder(thumbnailData),
} satisfies VideoMetadata;
}
if (mediaType === 'audio') {
const probeData = await ffprobe(tempPath);
return {
format: (probeData.format?.format_name || '').toLowerCase(),
size: toNumericField(probeData.format?.size),
duration: Math.ceil(Number(probeData.format?.duration || 0)),
} satisfies AudioMetadata;
}
throw new Error('Unsupported media type');
} finally {
await Promise.all(tempFiles.map((f) => fs.unlink(f).catch(() => {})));
}
}
return {validateMedia, processMetadata};
}

View File

@@ -0,0 +1,78 @@
/*
* 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 {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import {
SUPPORTED_EXTENSIONS,
SUPPORTED_MIME_TYPES,
type SupportedExtension,
} from '@fluxer/media_proxy/src/lib/MediaTypes';
import {getContentTypeFromFilename} from '@fluxer/mime_utils/src/ContentTypeUtils';
import {filetypeinfo} from 'magic-bytes.js';
export function createMimeTypeUtils(logger: LoggerInterface) {
const getMimeType = (buffer: Buffer, filename?: string): string | null => {
if (filename) {
const ext = filename.split('.').pop()?.toLowerCase();
if (ext && ext in SUPPORTED_EXTENSIONS) {
const mimeType = SUPPORTED_EXTENSIONS[ext as SupportedExtension];
return mimeType;
}
}
try {
const fileInfo = filetypeinfo(buffer);
if (fileInfo?.[0]?.mime && SUPPORTED_MIME_TYPES.has(fileInfo[0].mime)) {
return fileInfo[0].mime;
}
} catch (error) {
logger.error({error}, 'Failed to detect file type using magic bytes');
}
return null;
};
const getContentType = (filename: string): string => {
return getContentTypeFromFilename(filename);
};
const generateFilename = (mimeType: string, originalFilename?: string): string => {
const baseName = originalFilename ? originalFilename.split('.')[0] : 'file';
const mimeToExt = Object.entries(SUPPORTED_EXTENSIONS).reduce(
(acc, [ext, mime]) => {
acc[mime] = ext;
return acc;
},
{} as Record<string, string>,
);
const extension = mimeToExt[mimeType];
if (!extension) throw new Error(`Unsupported MIME type: ${mimeType}`);
return `${baseName}.${extension}`;
};
const getMediaCategory = (mimeType: string): string | null => {
const category = mimeType.split('/')[0] ?? '';
return ['image', 'video', 'audio'].includes(category) ? category : null;
};
return {getMimeType, generateFilename, getMediaCategory, getContentType};
}
export type MimeTypeUtils = ReturnType<typeof createMimeTypeUtils>;

View File

@@ -0,0 +1,120 @@
/*
* 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 path from 'node:path';
import * as ort from 'onnxruntime-node';
import sharp from 'sharp';
const MODEL_SIZE = 224;
interface NSFWCheckResult {
isNSFW: boolean;
probability: number;
predictions?: {
drawing: number;
hentai: number;
neutral: number;
porn: number;
sexy: number;
};
}
export class NSFWDetectionService {
private session: ort.InferenceSession | null = null;
private readonly NSFW_THRESHOLD = 0.85;
private modelPath: string;
constructor(options?: {modelPath?: string | undefined; nodeEnv?: string | undefined}) {
const nodeEnv = options?.nodeEnv ?? 'production';
this.modelPath =
options?.modelPath ??
(nodeEnv === 'production' ? '/opt/data/model.onnx' : path.join(process.cwd(), 'data', 'model.onnx'));
}
async initialize(): Promise<void> {
const modelBuffer = await fs.readFile(this.modelPath);
this.session = await ort.InferenceSession.create(modelBuffer);
}
async checkNSFW(filePath: string): Promise<NSFWCheckResult> {
const buffer = await fs.readFile(filePath);
return this.checkNSFWBuffer(buffer);
}
async checkNSFWBuffer(buffer: Buffer): Promise<NSFWCheckResult> {
if (!this.session) {
throw new Error('NSFW Detection service not initialized');
}
const processedImage = await this.preprocessImage(buffer);
const tensor = new ort.Tensor('float32', processedImage, [1, MODEL_SIZE, MODEL_SIZE, 3]);
const feeds = {input: tensor};
const results = await this.session.run(feeds);
const outputTensor = results['prediction'];
if (!outputTensor || !outputTensor.data) {
throw new Error('ONNX model output tensor data is undefined');
}
const predictions = Array.from(outputTensor.data as Float32Array);
const drawing = predictions[0] ?? 0;
const neutral = predictions[2] ?? 0;
const porn = predictions[3] ?? 0;
const sexy = predictions[4] ?? 0;
const predictionMap = {
drawing,
// NOTE: hentai: predictions[1], gives false positives
hentai: 0,
neutral,
porn,
sexy,
};
const nsfwProbability = predictionMap.hentai + predictionMap.porn + predictionMap.sexy;
return {
isNSFW: nsfwProbability > this.NSFW_THRESHOLD,
probability: nsfwProbability,
predictions: predictionMap,
};
}
private async preprocessImage(buffer: Buffer): Promise<Float32Array> {
const imageBuffer = await sharp(buffer)
.resize(MODEL_SIZE, MODEL_SIZE, {fit: 'fill'})
.removeAlpha()
.raw()
.toBuffer();
const float32Array = new Float32Array(MODEL_SIZE * MODEL_SIZE * 3);
const mean = [104, 117, 123];
for (let i = 0; i < imageBuffer.length; i += 3) {
float32Array[i] = (imageBuffer[i + 2] ?? 0) - (mean[0] ?? 0);
float32Array[i + 1] = (imageBuffer[i + 1] ?? 0) - (mean[1] ?? 0);
float32Array[i + 2] = (imageBuffer[i] ?? 0) - (mean[2] ?? 0);
}
return float32Array;
}
}

View File

@@ -0,0 +1,224 @@
/*
* 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 {PassThrough, Stream} from 'node:stream';
import {GetObjectCommand, HeadObjectCommand, S3Client, S3ServiceException} from '@aws-sdk/client-s3';
import type {S3Config} from '@fluxer/media_proxy/src/types/MediaProxyConfig';
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
import {HTTPException} from 'hono/http-exception';
const MAX_STREAM_BYTES = 500 * 1024 * 1024;
export function createS3Client(config: S3Config): S3Client {
return new S3Client({
endpoint: config.endpoint,
region: config.region,
forcePathStyle: true,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
});
}
export interface S3HeadResult {
contentLength: number;
contentType: string;
lastModified?: Date | undefined;
}
export function createS3Utils(client: S3Client, metrics?: MetricsInterface) {
const headS3Object = async (bucket: string, key: string): Promise<S3HeadResult> => {
const start = Date.now();
try {
const command = new HeadObjectCommand({
Bucket: bucket,
Key: key,
});
const {ContentLength, ContentType, LastModified} = await client.send(command);
metrics?.histogram({
name: 'media_proxy.upstream.latency',
dimensions: {operation: 'head'},
valueMs: Date.now() - start,
});
return {
contentLength: ContentLength ?? 0,
contentType: ContentType ?? 'application/octet-stream',
lastModified: LastModified,
};
} catch (error) {
metrics?.histogram({
name: 'media_proxy.upstream.latency',
dimensions: {operation: 'head'},
valueMs: Date.now() - start,
});
if (error instanceof S3ServiceException) {
metrics?.counter({
name: 'media_proxy.s3.error',
dimensions: {operation: 'head', error_type: error.name},
});
if (error.name === 'NotFound') {
throw new HTTPException(404);
}
}
throw error;
}
};
const readS3Object = async (bucket: string, key: string, range?: {start: number; end: number}) => {
const start = Date.now();
try {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
Range: range ? `bytes=${range.start}-${range.end}` : undefined,
});
const {Body, ContentLength, LastModified, ContentType} = await client.send(command);
assert(Body != null && ContentType != null);
if (range) {
assert(Body instanceof Stream);
const stream = Body instanceof PassThrough ? Body : Body.pipe(new PassThrough());
metrics?.histogram({
name: 'media_proxy.upstream.latency',
dimensions: {operation: 'read'},
valueMs: Date.now() - start,
});
return {data: stream, size: ContentLength || 0, contentType: ContentType, lastModified: LastModified};
}
const chunks: Array<Buffer> = [];
let totalSize = 0;
assert(Body instanceof Stream);
const stream = Body instanceof PassThrough ? Body : Body.pipe(new PassThrough());
for await (const chunk of stream) {
totalSize += chunk.length;
if (totalSize > MAX_STREAM_BYTES) throw new HTTPException(413);
chunks.push(chunk);
}
metrics?.histogram({
name: 'media_proxy.upstream.latency',
dimensions: {operation: 'read'},
valueMs: Date.now() - start,
});
return {data: Buffer.concat(chunks), size: totalSize, contentType: ContentType, lastModified: LastModified};
} catch (error) {
metrics?.histogram({
name: 'media_proxy.upstream.latency',
dimensions: {operation: 'read'},
valueMs: Date.now() - start,
});
if (error instanceof S3ServiceException) {
metrics?.counter({
name: 'media_proxy.s3.error',
dimensions: {operation: 'read', error_type: error.name},
});
if (error.name === 'NoSuchKey') {
throw new HTTPException(404);
}
}
throw error;
}
};
const streamS3Object = async (bucket: string, key: string) => {
const start = Date.now();
try {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
const {Body, ContentLength, LastModified, ContentType} = await client.send(command);
assert(Body != null && ContentType != null);
assert(Body instanceof Stream, 'Expected S3 response body to be a stream');
const stream = Body instanceof PassThrough ? Body : Body.pipe(new PassThrough());
metrics?.histogram({
name: 'media_proxy.upstream.latency',
dimensions: {operation: 'stream'},
valueMs: Date.now() - start,
});
return {stream, size: ContentLength || 0, contentType: ContentType, lastModified: LastModified};
} catch (error) {
metrics?.histogram({
name: 'media_proxy.upstream.latency',
dimensions: {operation: 'stream'},
valueMs: Date.now() - start,
});
if (error instanceof S3ServiceException) {
metrics?.counter({
name: 'media_proxy.s3.error',
dimensions: {operation: 'stream', error_type: error.name},
});
if (error.name === 'NoSuchKey') {
throw new HTTPException(404);
}
}
throw error;
}
};
return {headS3Object, readS3Object, streamS3Object};
}
export type S3Utils = ReturnType<typeof createS3Utils>;
export async function streamToBuffer(stream: ReadableStream<Uint8Array> | null): Promise<Buffer> {
if (!stream) {
return Buffer.alloc(0);
}
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);
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 Buffer.from(result);
}

View File

@@ -0,0 +1,39 @@
/*
* 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 {timingSafeEqual} from 'node:crypto';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import {createMiddleware} from 'hono/factory';
import {HTTPException} from 'hono/http-exception';
export function createInternalNetworkRequired(secretKey: string) {
return createMiddleware<HonoEnv>(async (ctx, next) => {
const authHeader = ctx.req.header('Authorization');
const expectedAuth = `Bearer ${secretKey}`;
if (!authHeader) {
throw new HTTPException(401, {message: 'Unauthorized'});
}
const authBuffer = Buffer.from(authHeader, 'utf8');
const expectedBuffer = Buffer.from(expectedAuth, 'utf8');
if (authBuffer.length !== expectedBuffer.length || !timingSafeEqual(authBuffer, expectedBuffer)) {
throw new HTTPException(401, {message: 'Unauthorized'});
}
await next();
});
}

View File

@@ -0,0 +1,61 @@
/*
* 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 {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {CloudflareEdgeIPService} from '@fluxer/media_proxy/src/lib/CloudflareEdgeIPService';
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import {createMiddleware} from 'hono/factory';
import {HTTPException} from 'hono/http-exception';
interface CloudflareFirewallOptions {
enabled: boolean;
exemptPaths?: Array<string>;
}
export function createCloudflareFirewall(
ipService: CloudflareEdgeIPService,
logger: LoggerInterface,
{enabled, exemptPaths = ['/_health', '/_metadata']}: CloudflareFirewallOptions,
) {
return createMiddleware<HonoEnv>(async (ctx, next) => {
if (!enabled) {
await next();
return;
}
const path = ctx.req.path;
if (exemptPaths.some((prefix) => path === prefix || path.startsWith(prefix))) {
await next();
return;
}
const xff = ctx.req.header('x-forwarded-for');
if (!xff) {
logger.warn({path}, 'Rejected request without X-Forwarded-For header');
throw new HTTPException(403, {message: 'Forbidden'});
}
const connectingIP = xff.split(',')[0]?.trim();
if (!connectingIP || !ipService.isFromCloudflareEdge(connectingIP)) {
logger.warn({connectingIP, path}, 'Rejected request from non-Cloudflare edge IP');
throw new HTTPException(403, {message: 'Forbidden'});
}
await next();
});
}

View File

@@ -0,0 +1,154 @@
/*
* 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 {ErrorContext, ErrorType, HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
import {createMiddleware} from 'hono/factory';
function getRouteFromPath(path: string): string | null {
if (path === '/_health' || path === '/internal/telemetry') return null;
if (path.startsWith('/avatars/')) return 'avatars';
if (path.startsWith('/icons/')) return 'icons';
if (path.startsWith('/banners/')) return 'banners';
if (path.startsWith('/emojis/')) return 'emojis';
if (path.startsWith('/stickers/')) return 'stickers';
if (path.startsWith('/attachments/')) return 'attachments';
if (path.startsWith('/external/')) return 'external';
if (path.startsWith('/guilds/')) return 'guild_assets';
return 'other';
}
function getErrorTypeFromStatus(status: number): ErrorType {
switch (status) {
case 400:
return 'bad_request';
case 401:
return 'unauthorized';
case 403:
return 'forbidden';
case 404:
return 'not_found';
case 408:
return 'timeout';
case 413:
return 'payload_too_large';
default:
if (status >= 500 && status < 600) {
return 'upstream_5xx';
}
return 'other';
}
}
export function createMetricsMiddleware(metrics: MetricsInterface) {
return createMiddleware<HonoEnv>(async (ctx, next) => {
const start = Date.now();
let errorType: ErrorType | undefined;
let errorSource: string | undefined;
try {
await next();
} catch (error) {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('timeout') || message.includes('timed out') || message.includes('etimedout')) {
errorType = 'timeout';
errorSource = 'network';
} else if (
message.includes('econnrefused') ||
message.includes('econnreset') ||
message.includes('enotfound')
) {
errorType = 'upstream_5xx';
errorSource = 'network';
}
}
throw error;
} finally {
const duration = Date.now() - start;
const route = getRouteFromPath(ctx.req.path);
if (route !== null) {
const status = ctx.res.status;
const baseDimensions = {
'http.request.method': ctx.req.method,
'url.path': route,
'http.response.status_code': String(status),
};
metrics.histogram({
name: 'http.server.request.duration',
dimensions: baseDimensions,
valueMs: duration,
});
metrics.counter({
name: 'http.server.request.count',
dimensions: baseDimensions,
value: 1,
});
metrics.histogram({
name: 'media_proxy.latency',
dimensions: {route},
valueMs: duration,
});
metrics.counter({
name: 'media_proxy.request',
dimensions: {route, status: String(status)},
});
if (status >= 400) {
const errorContext = ctx.get('metricsErrorContext') as ErrorContext | undefined;
const finalErrorType = errorContext?.errorType ?? errorType ?? getErrorTypeFromStatus(status);
const finalErrorSource = errorContext?.errorSource ?? errorSource ?? 'handler';
metrics.counter({
name: 'media_proxy.failure',
dimensions: {
route,
status: String(status),
error_type: finalErrorType,
error_source: finalErrorSource,
},
});
} else {
metrics.counter({
name: 'media_proxy.success',
dimensions: {route, status: String(status)},
});
}
const contentLength = ctx.res.headers.get('content-length');
if (contentLength) {
const bytes = Number.parseInt(contentLength, 10);
if (!Number.isNaN(bytes)) {
metrics.counter({
name: 'media_proxy.bytes',
dimensions: {route},
value: bytes,
});
}
}
}
}
});
}

View File

@@ -0,0 +1,55 @@
/*
* 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 {
DEFAULT_MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUE,
MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUES,
} from '@fluxer/constants/src/MediaProxyImageSizes';
import * as v from 'valibot';
export const ImageParamSchema = v.object({
id: v.string(),
filename: v.pipe(
v.string(),
v.minLength(1),
v.maxLength(100),
v.regex(/^[a-zA-Z0-9_]+\.[a-zA-Z0-9]+$/, 'Invalid filename'),
),
});
export const ImageQuerySchema = v.object({
size: v.optional(v.picklist(MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUES), DEFAULT_MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUE),
format: v.optional(v.picklist(['png', 'jpg', 'jpeg', 'webp', 'gif']), 'webp'),
quality: v.optional(v.picklist(['high', 'low', 'lossless']), 'high'),
animated: v.pipe(
v.optional(v.picklist(['true', 'false']), 'false'),
v.transform((v) => v === 'true'),
),
});
export const ExternalQuerySchema = v.object({
width: v.optional(v.pipe(v.string(), v.transform(Number), v.number(), v.integer(), v.minValue(1), v.maxValue(4096))),
height: v.optional(v.pipe(v.string(), v.transform(Number), v.number(), v.integer(), v.minValue(1), v.maxValue(4096))),
format: v.optional(v.picklist(['png', 'jpg', 'jpeg', 'webp', 'gif'])),
quality: v.optional(v.picklist(['high', 'low', 'lossless']), 'lossless'),
animated: v.pipe(
v.optional(v.picklist(['true', 'false']), 'false'),
v.transform((v) => v === 'true'),
),
});

View File

@@ -0,0 +1,154 @@
/*
* 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/promises';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import {
computeFrameSampleTimestamps,
FFmpegTimeoutError,
type FrameExtractor,
ffprobe,
} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
import type {S3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
import type {FrameRequest, FrameResponse, IFrameService} from '@fluxer/media_proxy/src/types/MediaProxyServices';
import sharp from 'sharp';
import {temporaryFile} from 'tempy';
export interface FrameServiceDeps {
s3Utils: S3Utils;
mimeTypeUtils: MimeTypeUtils;
frameExtractor: FrameExtractor;
logger: LoggerInterface;
bucketUploads: string;
}
export function createFrameService(deps: FrameServiceDeps): IFrameService {
const {s3Utils, mimeTypeUtils, frameExtractor, logger, bucketUploads} = deps;
const {readS3Object} = s3Utils;
const {getMimeType, getMediaCategory} = mimeTypeUtils;
const {extractFramesAtTimes} = frameExtractor;
async function extractFrames(request: FrameRequest): Promise<FrameResponse> {
let data: Buffer;
let filename: string;
let mimeType: string | null | undefined;
try {
if (request.type === 'upload') {
const result = await readS3Object(bucketUploads, request.upload_filename);
assert(result.data instanceof Buffer);
data = result.data;
filename = request.upload_filename;
} else {
const result = await readS3Object(request.bucket, request.key);
assert(result.data instanceof Buffer);
data = result.data;
filename = request.key.split('/').pop() ?? request.key;
}
mimeType = getMimeType(data, filename);
if (!mimeType) {
logger.warn({source: filename}, 'Unable to determine file type for frame extraction, returning empty frames');
return {frames: []};
}
} catch (error) {
logger.error({error, request}, 'Failed to read file for frame extraction, returning empty frames');
return {frames: []};
}
if (!mimeType || !data) {
return {frames: []};
}
const tempFilePath = temporaryFile({extension: 'tmp'});
const tempFiles: Array<string> = [tempFilePath];
try {
await fs.writeFile(tempFilePath, data);
const probeResult = await ffprobe(tempFilePath);
const rawDuration = probeResult.format?.duration;
const durationSeconds =
typeof rawDuration === 'string' && Number.isFinite(Number.parseFloat(rawDuration))
? Number.parseFloat(rawDuration)
: null;
const hasVideoStream = probeResult.streams?.some((stream) => stream.codec_type === 'video') ?? false;
let isAnimatedImage = false;
if (getMediaCategory(mimeType) === 'image') {
try {
const metadata = await sharp(data, {animated: true}).metadata();
isAnimatedImage = (metadata.pages ?? 1) > 1;
} catch (error) {
logger.debug({error, source: filename}, 'Unable to detect animation pages');
}
}
const isRealVideo = hasVideoStream && durationSeconds !== null && durationSeconds > 0;
const frames: Array<{timestamp: number; mimeType: string; buffer: Buffer}> = [];
if (isRealVideo || isAnimatedImage) {
const timestamps = computeFrameSampleTimestamps(durationSeconds);
const framePaths = await extractFramesAtTimes(tempFilePath, timestamps);
for (let i = 0; i < framePaths.length; i++) {
const framePath = framePaths[i];
if (!framePath) continue;
tempFiles.push(framePath);
const frameData = await fs.readFile(framePath);
frames.push({
timestamp: timestamps[i] ?? 0,
mimeType: 'image/jpeg',
buffer: frameData,
});
}
} else {
frames.push({
timestamp: 0,
mimeType,
buffer: data,
});
}
return {
frames: frames.map((frame) => ({
timestamp: frame.timestamp,
mime_type: frame.mimeType,
base64: frame.buffer.toString('base64'),
})),
};
} catch (error) {
logger.error({error, source: filename}, 'Failed to extract media frames, returning empty frames');
if (error instanceof FFmpegTimeoutError) {
throw new Error(`Frame extraction timed out: ${error.operation}`);
}
return {frames: []};
} finally {
await Promise.all(
tempFiles.map((file) => fs.unlink(file).catch(() => logger.error(`Failed to delete temp file: ${file}`))),
);
}
}
return {
extractFrames,
};
}

View File

@@ -0,0 +1,220 @@
/*
* 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 crypto from 'node:crypto';
import fs from 'node:fs/promises';
import type {HttpClient} from '@fluxer/http_client/src/HttpClientTypes';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {FFmpegUtilsType} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
import type {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
import type {MediaValidator} from '@fluxer/media_proxy/src/lib/MediaValidation';
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
import type {NSFWDetectionService} from '@fluxer/media_proxy/src/lib/NSFWDetectionService';
import type {S3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
import {streamToBuffer} from '@fluxer/media_proxy/src/lib/S3Utils';
import type {
IMetadataService,
MetadataRequest,
MetadataResponse,
ThumbnailRequest,
} from '@fluxer/media_proxy/src/types/MediaProxyServices';
import sharp from 'sharp';
import {temporaryFile} from 'tempy';
export interface MetadataServiceDeps {
coalescer: InMemoryCoalescer;
nsfwDetectionService: NSFWDetectionService;
s3Utils: S3Utils;
httpClient: HttpClient;
mimeTypeUtils: MimeTypeUtils;
mediaValidator: MediaValidator;
ffmpegUtils: FFmpegUtilsType;
logger: LoggerInterface;
bucketUploads: string;
}
export function createMetadataService(deps: MetadataServiceDeps): IMetadataService {
const {
coalescer,
nsfwDetectionService,
s3Utils,
httpClient,
mimeTypeUtils,
mediaValidator,
ffmpegUtils,
logger,
bucketUploads,
} = deps;
const {readS3Object} = s3Utils;
const {getMimeType, generateFilename} = mimeTypeUtils;
const {validateMedia, processMetadata} = mediaValidator;
const {createThumbnail} = ffmpegUtils;
async function getMetadata(request: MetadataRequest): Promise<MetadataResponse> {
const cacheKey = (() => {
switch (request.type) {
case 'base64':
return `base64_${request.base64}`;
case 'upload':
return `upload_${request.upload_filename}`;
case 'external':
return `external_${request.url}_${request.with_base64}`;
case 's3':
return `s3_${request.bucket}_${request.key}_${request.with_base64}`;
}
})();
return coalescer.coalesce(cacheKey, async () => {
const tempFiles: Array<string> = [];
try {
const {buffer, filename} = await (async () => {
switch (request.type) {
case 'base64':
return {buffer: Buffer.from(request.base64, 'base64'), filename: undefined};
case 'upload': {
const {data} = await readS3Object(bucketUploads, request.upload_filename);
assert(data instanceof Buffer);
return {buffer: data, filename: request.filename ?? request.upload_filename};
}
case 's3': {
const {data} = await readS3Object(request.bucket, request.key);
assert(data instanceof Buffer);
const fname = request.key.substring(request.key.lastIndexOf('/') + 1);
return {buffer: data, filename: fname || undefined};
}
case 'external': {
const response = await httpClient.sendRequest({url: request.url});
if (response.status !== 200) {
throw new Error('Failed to fetch media');
}
const url = new URL(request.url);
const fname = url.pathname.substring(url.pathname.lastIndexOf('/') + 1);
return {buffer: await streamToBuffer(response.stream), filename: fname || undefined};
}
default:
throw new Error('Invalid request type');
}
})();
let effectiveFilename = filename;
if (!effectiveFilename && request.type === 'base64') {
const detectedMime = getMimeType(buffer);
if (detectedMime) {
effectiveFilename = generateFilename(detectedMime);
} else {
const metadata = await sharp(buffer).metadata();
if (metadata.format) {
effectiveFilename = `image.${metadata.format.toLowerCase()}`;
}
}
}
if (!effectiveFilename) {
throw new Error('Cannot determine file type');
}
const mimeType = await validateMedia(buffer, effectiveFilename);
const metadata = await processMetadata(mimeType, buffer);
const contentHash = crypto.createHash('sha256').update(buffer).digest('hex');
let nsfw = false;
let nsfwProbability = 0;
let nsfwPredictions: Record<string, number> = {};
if (!request.isNSFWAllowed) {
const isImageOrVideo = mimeType.startsWith('image/') || mimeType.startsWith('video/');
if (isImageOrVideo) {
try {
let checkBuffer = buffer;
if (mimeType.startsWith('video/')) {
const videoExtension = mimeType.split('/')[1] ?? 'tmp';
const tempPath = temporaryFile({extension: videoExtension});
tempFiles.push(tempPath);
await fs.writeFile(tempPath, buffer);
const thumbnailPath = await createThumbnail(tempPath);
tempFiles.push(thumbnailPath);
checkBuffer = await fs.readFile(thumbnailPath);
}
const nsfwResult = await nsfwDetectionService.checkNSFWBuffer(checkBuffer);
nsfw = nsfwResult.isNSFW;
nsfwProbability = nsfwResult.probability;
nsfwPredictions = nsfwResult.predictions ?? {};
} catch (error) {
logger.error({error}, 'Failed to perform NSFW detection');
}
}
}
return {
...metadata,
content_type: mimeType,
content_hash: contentHash,
nsfw,
nsfw_probability: nsfwProbability,
nsfw_predictions: nsfwPredictions,
base64:
(request.type === 'external' || request.type === 's3') && request.with_base64
? buffer.toString('base64')
: undefined,
};
} finally {
await Promise.all(
tempFiles.map((file) => fs.unlink(file).catch(() => logger.error(`Failed to delete temp file: ${file}`))),
);
}
});
}
async function getThumbnail(request: ThumbnailRequest): Promise<Buffer> {
const {data} = await readS3Object(bucketUploads, request.upload_filename);
assert(data instanceof Buffer);
const mimeType = getMimeType(data, request.upload_filename);
if (!mimeType || !mimeType.startsWith('video/')) {
throw new Error('File is not a video');
}
const videoExtension = mimeType.split('/')[1] ?? 'tmp';
const tempPath = temporaryFile({extension: videoExtension});
const tempFiles: Array<string> = [tempPath];
try {
await fs.writeFile(tempPath, data);
const thumbnailPath = await createThumbnail(tempPath);
tempFiles.push(thumbnailPath);
return await fs.readFile(thumbnailPath);
} finally {
await Promise.all(
tempFiles.map((file) => fs.unlink(file).catch(() => logger.error(`Failed to delete temp file: ${file}`))),
);
}
}
return {
getMetadata,
getThumbnail,
};
}

View File

@@ -0,0 +1,40 @@
/*
* 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 ErrorType =
| 'timeout'
| 'upstream_5xx'
| 'not_found'
| 'bad_request'
| 'forbidden'
| 'unauthorized'
| 'payload_too_large'
| 'other';
export interface ErrorContext {
errorType?: ErrorType;
errorSource?: string;
}
export interface HonoEnv {
Variables: {
tempFiles: Array<string>;
metricsErrorContext?: ErrorContext;
};
}

View File

@@ -0,0 +1,37 @@
/*
* 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 S3Config {
endpoint: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
bucketCdn: string;
bucketUploads: string;
bucketStatic?: string | undefined;
}
export interface MediaProxyConfig {
nodeEnv: 'development' | 'production';
secretKey: string;
requireCloudflareEdge: boolean;
staticMode: boolean;
s3: S3Config;
nsfwModelPath?: string | undefined;
}

View File

@@ -0,0 +1,70 @@
/*
* 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 MetadataRequest =
| {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 MetadataResponse {
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 interface ThumbnailRequest {
upload_filename: string;
}
export interface IMetadataService {
getMetadata(request: MetadataRequest): Promise<MetadataResponse>;
getThumbnail(request: ThumbnailRequest): Promise<Buffer>;
}
export type FrameRequest = {type: 'upload'; upload_filename: string} | {type: 's3'; bucket: string; key: string};
export interface FrameData {
timestamp: number;
mime_type: string;
base64: string;
}
export interface FrameResponse {
frames: Array<FrameData>;
}
export interface IFrameService {
extractFrames(request: FrameRequest): Promise<FrameResponse>;
}
export interface MediaProxyServices {
metadataService: IMetadataService;
frameService: IFrameService;
}

View File

@@ -0,0 +1,43 @@
/*
* 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 HistogramParams {
name: string;
dimensions?: Record<string, string>;
valueMs: number;
}
export interface GaugeParams {
name: string;
dimensions?: Record<string, string>;
value: number;
}
export interface MetricsInterface {
counter(params: CounterParams): void;
histogram(params: HistogramParams): void;
gauge(params: GaugeParams): void;
isEnabled(): boolean;
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Attributes} from '@opentelemetry/api';
export interface SpanOptions {
name: string;
attributes?: Attributes | undefined;
}
export interface TracingInterface {
withSpan<T>(options: SpanOptions, fn: () => Promise<T>): Promise<T>;
addSpanEvent(name: string, attributes?: Attributes): void;
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createHttpClient, type HttpError} from '@fluxer/media_proxy/src/utils/FetchUtils';
import {describe, expect, test} from 'vitest';
describe('media proxy request URL policy', () => {
test('blocks localhost metadata target', async () => {
const client = createHttpClient('fluxer-media-proxy-test');
const expectedError: Partial<HttpError> = {
isExpected: true,
};
await expect(client.sendRequest({url: 'http://localhost/_metadata'})).rejects.toMatchObject(expectedError);
});
test('blocks private IP metadata target', async () => {
const client = createHttpClient('fluxer-media-proxy-test');
const expectedError: Partial<HttpError> = {
isExpected: true,
};
await expect(client.sendRequest({url: 'http://127.0.0.1:8080/path'})).rejects.toMatchObject(expectedError);
});
});

View File

@@ -0,0 +1,116 @@
/*
* 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 {createHttpClient as createHttpClientCore} from '@fluxer/http_client/src/HttpClient';
import type {HttpClient, RequestOptions, StreamResponse} from '@fluxer/http_client/src/HttpClientTypes';
import {HttpError as CoreHttpError} from '@fluxer/http_client/src/HttpError';
import {createPublicInternetRequestUrlPolicy} from '@fluxer/http_client/src/PublicInternetRequestUrlPolicy';
import type {ErrorType} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
export class HttpError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly response?: Response,
public readonly isExpected = false,
public readonly errorType?: ErrorType,
) {
super(message);
this.name = 'HttpError';
}
}
function convertHttpError(err: unknown): never {
if (err instanceof CoreHttpError && err instanceof Error) {
throw new HttpError(
err.message,
err.status,
err.response,
err.isExpected,
(err.errorType as ErrorType) ?? undefined,
);
}
throw err;
}
export function createHttpClient(
userAgent: string,
options?: {metrics?: MetricsInterface | undefined; tracing?: TracingInterface | undefined},
): HttpClient {
const {metrics, tracing} = options ?? {};
const coreClient: HttpClient = createHttpClientCore({
userAgent,
requestUrlPolicy: createPublicInternetRequestUrlPolicy(),
telemetry: {
metrics: metrics
? {
counter: (params) => {
const newName = params.name.replace('http_client', 'media_proxy.origin');
metrics.counter({...params, name: newName});
},
histogram: (params) => {
const newName = params.name.replace('http_client', 'media_proxy.origin');
metrics.histogram({...params, name: newName});
},
}
: undefined,
tracing: tracing
? {
withSpan: (spanOpts, fn) =>
tracing.withSpan(
{
name: spanOpts.name.replace('http_client.fetch', 'origin.fetch'),
attributes: spanOpts.attributes as Record<string, string | number | boolean | undefined>,
},
fn,
),
}
: undefined,
},
});
async function sendRequest(opts: RequestOptions): Promise<StreamResponse> {
try {
const result = await coreClient.sendRequest(opts);
const contentLength = result.headers.get('content-length');
if (contentLength && metrics) {
const bytes = Number.parseInt(contentLength, 10);
if (!Number.isNaN(bytes)) {
metrics.counter({
name: 'media_proxy.origin.bytes',
dimensions: {method: opts.method ?? 'GET'},
value: bytes,
});
}
}
return result;
} catch (error) {
convertHttpError(error);
}
}
return {
request: sendRequest,
sendRequest,
streamToString: coreClient.streamToString,
};
}

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfigs/package.json",
"compilerOptions": {},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,39 @@
/*
* 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 path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
},
});