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,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);
}
};
}