refactor progress
This commit is contained in:
162
packages/media_proxy/src/controllers/AttachmentsController.tsx
Normal file
162
packages/media_proxy/src/controllers/AttachmentsController.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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));
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
195
packages/media_proxy/src/controllers/ExternalMediaController.tsx
Normal file
195
packages/media_proxy/src/controllers/ExternalMediaController.tsx
Normal 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));
|
||||
};
|
||||
}
|
||||
@@ -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'});
|
||||
}
|
||||
};
|
||||
}
|
||||
176
packages/media_proxy/src/controllers/ImageController.test.tsx
Normal file
176
packages/media_proxy/src/controllers/ImageController.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
293
packages/media_proxy/src/controllers/ImageController.tsx
Normal file
293
packages/media_proxy/src/controllers/ImageController.tsx
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
47
packages/media_proxy/src/controllers/MetadataController.tsx
Normal file
47
packages/media_proxy/src/controllers/MetadataController.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {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'});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
120
packages/media_proxy/src/controllers/StickerController.test.tsx
Normal file
120
packages/media_proxy/src/controllers/StickerController.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
113
packages/media_proxy/src/controllers/StickerController.tsx
Normal file
113
packages/media_proxy/src/controllers/StickerController.tsx
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
87
packages/media_proxy/src/controllers/ThemeController.tsx
Normal file
87
packages/media_proxy/src/controllers/ThemeController.tsx
Normal 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));
|
||||
}
|
||||
};
|
||||
}
|
||||
93
packages/media_proxy/src/controllers/ThumbnailController.tsx
Normal file
93
packages/media_proxy/src/controllers/ThumbnailController.tsx
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user