refactor progress
This commit is contained in:
387
packages/media_proxy/src/App.tsx
Normal file
387
packages/media_proxy/src/App.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import {createErrorHandler} from '@fluxer/errors/src/ErrorHandler';
|
||||
import {applyMiddlewareStack} from '@fluxer/hono/src/middleware/MiddlewareStack';
|
||||
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
|
||||
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import {createAttachmentsHandler} from '@fluxer/media_proxy/src/controllers/AttachmentsController';
|
||||
import {createExternalMediaHandler} from '@fluxer/media_proxy/src/controllers/ExternalMediaController';
|
||||
import {createFrameExtractionHandler} from '@fluxer/media_proxy/src/controllers/FrameExtractionController';
|
||||
import {
|
||||
createGuildMemberImageRouteHandler,
|
||||
createImageRouteHandler,
|
||||
createSimpleImageRouteHandler,
|
||||
} from '@fluxer/media_proxy/src/controllers/ImageController';
|
||||
import {createMetadataHandler} from '@fluxer/media_proxy/src/controllers/MetadataController';
|
||||
import {createStaticProxyHandler} from '@fluxer/media_proxy/src/controllers/StaticProxyController';
|
||||
import {createStickerRouteHandler} from '@fluxer/media_proxy/src/controllers/StickerController';
|
||||
import {createThemeHandler} from '@fluxer/media_proxy/src/controllers/ThemeController';
|
||||
import {createThumbnailHandler} from '@fluxer/media_proxy/src/controllers/ThumbnailController';
|
||||
import {CloudflareEdgeIPService} from '@fluxer/media_proxy/src/lib/CloudflareEdgeIPService';
|
||||
import {createCodecValidator} from '@fluxer/media_proxy/src/lib/CodecValidation';
|
||||
import {createFFmpegUtils, createFrameExtractor} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
|
||||
import {createImageProcessor} from '@fluxer/media_proxy/src/lib/ImageProcessing';
|
||||
import {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
|
||||
import {createMediaTransformService} from '@fluxer/media_proxy/src/lib/MediaTransformService';
|
||||
import {createMediaValidator} from '@fluxer/media_proxy/src/lib/MediaValidation';
|
||||
import {createMimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
|
||||
import {NSFWDetectionService} from '@fluxer/media_proxy/src/lib/NSFWDetectionService';
|
||||
import {createS3Client, createS3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
|
||||
import {createInternalNetworkRequired} from '@fluxer/media_proxy/src/middleware/AuthMiddleware';
|
||||
import {createCloudflareFirewall} from '@fluxer/media_proxy/src/middleware/CloudflareFirewall';
|
||||
import {createMetricsMiddleware} from '@fluxer/media_proxy/src/middleware/MetricsMiddleware';
|
||||
import {createFrameService} from '@fluxer/media_proxy/src/services/FrameService';
|
||||
import {createMetadataService} from '@fluxer/media_proxy/src/services/MetadataService';
|
||||
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
|
||||
import type {MediaProxyConfig} from '@fluxer/media_proxy/src/types/MediaProxyConfig';
|
||||
import type {MediaProxyServices} from '@fluxer/media_proxy/src/types/MediaProxyServices';
|
||||
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
|
||||
import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
|
||||
import {createHttpClient} from '@fluxer/media_proxy/src/utils/FetchUtils';
|
||||
import type {RateLimitService} from '@fluxer/rate_limit/src/RateLimitService';
|
||||
import {captureException} from '@fluxer/sentry/src/Sentry';
|
||||
import {Hono} from 'hono';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import * as v from 'valibot';
|
||||
|
||||
const FLUXER_USER_AGENT = 'Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)';
|
||||
|
||||
export interface CreateMediaProxyAppOptions {
|
||||
config: MediaProxyConfig;
|
||||
logger: LoggerInterface;
|
||||
metrics?: MetricsInterface;
|
||||
tracing?: TracingInterface;
|
||||
requestMetricsCollector?: MetricsCollector;
|
||||
requestTracing?: TracingOptions;
|
||||
onTelemetryRequest?: () => Promise<{telemetry_enabled: boolean; service: string; timestamp: string}>;
|
||||
rateLimitService?: RateLimitService | null;
|
||||
rateLimitConfig?: {
|
||||
enabled: boolean;
|
||||
maxAttempts: number;
|
||||
windowMs: number;
|
||||
skipPaths?: Array<string>;
|
||||
} | null;
|
||||
publicOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaProxyAppResult {
|
||||
app: Hono<HonoEnv>;
|
||||
shutdown: () => Promise<void>;
|
||||
services?: MediaProxyServices;
|
||||
}
|
||||
|
||||
export async function createMediaProxyApp(options: CreateMediaProxyAppOptions): Promise<MediaProxyAppResult> {
|
||||
const {
|
||||
config,
|
||||
logger,
|
||||
metrics,
|
||||
tracing,
|
||||
requestMetricsCollector,
|
||||
requestTracing,
|
||||
onTelemetryRequest,
|
||||
rateLimitService,
|
||||
rateLimitConfig,
|
||||
publicOnly = false,
|
||||
} = options;
|
||||
|
||||
const app = new Hono<HonoEnv>({strict: true});
|
||||
|
||||
applyMiddlewareStack(app, {
|
||||
requestId: {},
|
||||
tracing: requestTracing,
|
||||
metrics: requestMetricsCollector
|
||||
? {
|
||||
enabled: true,
|
||||
collector: requestMetricsCollector,
|
||||
skipPaths: ['/_health', '/internal/telemetry'],
|
||||
}
|
||||
: undefined,
|
||||
logger: {
|
||||
log: (data) => {
|
||||
if (data.path !== '/_health' && data.path !== '/internal/telemetry') {
|
||||
logger.info(
|
||||
{
|
||||
method: data.method,
|
||||
path: data.path,
|
||||
status: data.status,
|
||||
durationMs: data.durationMs,
|
||||
},
|
||||
'Request completed',
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
rateLimit:
|
||||
rateLimitService && rateLimitConfig?.enabled
|
||||
? {
|
||||
enabled: true,
|
||||
service: rateLimitService,
|
||||
maxAttempts: rateLimitConfig.maxAttempts,
|
||||
windowMs: rateLimitConfig.windowMs,
|
||||
skipPaths: rateLimitConfig.skipPaths ?? ['/_health'],
|
||||
}
|
||||
: undefined,
|
||||
customMiddleware: [
|
||||
async (ctx, next) => {
|
||||
ctx.set('tempFiles', []);
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
const tempFiles = ctx.get('tempFiles') as Array<string>;
|
||||
await Promise.all(
|
||||
tempFiles.map((file: string) =>
|
||||
fs.unlink(file).catch(() => logger.error(`Failed to delete temp file: ${file}`)),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
],
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
|
||||
if (metrics) {
|
||||
app.use('*', createMetricsMiddleware(metrics));
|
||||
}
|
||||
|
||||
const s3Client = createS3Client(config.s3);
|
||||
const s3Utils = createS3Utils(s3Client, metrics);
|
||||
const mimeTypeUtils = createMimeTypeUtils(logger);
|
||||
const codecValidator = createCodecValidator(logger);
|
||||
const ffmpegUtils = createFFmpegUtils({metrics, tracing});
|
||||
const frameExtractor = createFrameExtractor({tracing});
|
||||
const imageProcessor = createImageProcessor({metrics, tracing});
|
||||
const mediaValidator = createMediaValidator(mimeTypeUtils, codecValidator, ffmpegUtils);
|
||||
const mediaTransformService = createMediaTransformService({imageProcessor, ffmpegUtils, mimeTypeUtils});
|
||||
const httpClient = createHttpClient(FLUXER_USER_AGENT, {metrics, tracing});
|
||||
const coalescer = new InMemoryCoalescer({metrics, tracing});
|
||||
|
||||
logger.info('Initialized in-memory request coalescer');
|
||||
|
||||
const fetchForCloudflare = async (url: string, method: string) => {
|
||||
const response = await httpClient.sendRequest({url, method: method as 'GET' | 'POST' | 'HEAD'});
|
||||
return {status: response.status, stream: response.stream};
|
||||
};
|
||||
|
||||
const cloudflareEdgeIPService = new CloudflareEdgeIPService(logger, fetchForCloudflare);
|
||||
|
||||
if (config.requireCloudflareEdge) {
|
||||
await cloudflareEdgeIPService.initialize();
|
||||
logger.info('Initialized Cloudflare edge IP allowlist');
|
||||
} else {
|
||||
logger.info('Cloudflare edge IP allowlist disabled');
|
||||
}
|
||||
|
||||
const cloudflareFirewall = createCloudflareFirewall(cloudflareEdgeIPService, logger, {
|
||||
enabled: config.requireCloudflareEdge,
|
||||
});
|
||||
|
||||
app.use('*', cloudflareFirewall);
|
||||
|
||||
app.get('/_health', (ctx) => ctx.text('OK'));
|
||||
|
||||
app.get('/internal/telemetry', async (ctx) => {
|
||||
if (onTelemetryRequest) {
|
||||
return ctx.json(await onTelemetryRequest());
|
||||
}
|
||||
return ctx.json({
|
||||
telemetry_enabled: Boolean(metrics),
|
||||
service: 'fluxer_media_proxy',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
let exposedServices: MediaProxyServices | undefined;
|
||||
|
||||
if (config.staticMode) {
|
||||
logger.info('Media proxy running in STATIC MODE - proxying all requests to the static bucket');
|
||||
|
||||
const handleStaticProxyRequest = createStaticProxyHandler({
|
||||
s3Utils,
|
||||
bucketStatic: config.s3.bucketStatic,
|
||||
});
|
||||
|
||||
app.all('*', handleStaticProxyRequest);
|
||||
} else {
|
||||
const nsfwDetectionService = new NSFWDetectionService({
|
||||
modelPath: config.nsfwModelPath,
|
||||
nodeEnv: config.nodeEnv,
|
||||
});
|
||||
await nsfwDetectionService.initialize();
|
||||
logger.info('Initialized NSFW detection service');
|
||||
|
||||
const metadataService = createMetadataService({
|
||||
coalescer,
|
||||
nsfwDetectionService,
|
||||
s3Utils,
|
||||
httpClient,
|
||||
mimeTypeUtils,
|
||||
mediaValidator,
|
||||
ffmpegUtils,
|
||||
logger,
|
||||
bucketUploads: config.s3.bucketUploads,
|
||||
});
|
||||
|
||||
const frameService = createFrameService({
|
||||
s3Utils,
|
||||
mimeTypeUtils,
|
||||
frameExtractor,
|
||||
logger,
|
||||
bucketUploads: config.s3.bucketUploads,
|
||||
});
|
||||
|
||||
exposedServices = {metadataService, frameService};
|
||||
|
||||
const imageControllerDeps = {
|
||||
coalescer,
|
||||
s3Utils,
|
||||
mimeTypeUtils,
|
||||
imageProcessor,
|
||||
bucketCdn: config.s3.bucketCdn,
|
||||
};
|
||||
|
||||
const handleImageRoute = createImageRouteHandler(imageControllerDeps);
|
||||
const handleSimpleImageRoute = createSimpleImageRouteHandler(imageControllerDeps);
|
||||
const handleGuildMemberImageRoute = createGuildMemberImageRouteHandler(imageControllerDeps);
|
||||
const handleStickerRoute = createStickerRouteHandler({
|
||||
coalescer,
|
||||
s3Utils,
|
||||
bucketCdn: config.s3.bucketCdn,
|
||||
});
|
||||
|
||||
const processExternalMedia = createExternalMediaHandler({
|
||||
coalescer,
|
||||
httpClient,
|
||||
mimeTypeUtils,
|
||||
mediaValidator,
|
||||
mediaTransformService,
|
||||
logger,
|
||||
secretKey: config.secretKey,
|
||||
metrics,
|
||||
tracing,
|
||||
});
|
||||
|
||||
const handleAttachmentsRoute = createAttachmentsHandler({
|
||||
coalescer,
|
||||
s3Client,
|
||||
s3Utils,
|
||||
mimeTypeUtils,
|
||||
mediaValidator,
|
||||
mediaTransformService,
|
||||
logger,
|
||||
bucketCdn: config.s3.bucketCdn,
|
||||
});
|
||||
|
||||
const handleThemeRequest = createThemeHandler({
|
||||
s3Utils,
|
||||
bucketCdn: config.s3.bucketCdn,
|
||||
});
|
||||
|
||||
if (!publicOnly) {
|
||||
const InternalNetworkRequired = createInternalNetworkRequired(config.secretKey);
|
||||
|
||||
const handleMetadataRequest = createMetadataHandler({
|
||||
metadataService,
|
||||
logger,
|
||||
});
|
||||
|
||||
const handleThumbnailRequest = createThumbnailHandler({
|
||||
s3Utils,
|
||||
mimeTypeUtils,
|
||||
ffmpegUtils,
|
||||
logger,
|
||||
bucketUploads: config.s3.bucketUploads,
|
||||
});
|
||||
|
||||
const handleFrameExtraction = createFrameExtractionHandler({
|
||||
frameService,
|
||||
logger,
|
||||
});
|
||||
|
||||
app.post('/_metadata', InternalNetworkRequired, handleMetadataRequest);
|
||||
app.post('/_thumbnail', InternalNetworkRequired, handleThumbnailRequest);
|
||||
app.post('/_frames', InternalNetworkRequired, handleFrameExtraction);
|
||||
}
|
||||
|
||||
app.get('/avatars/:id/:filename', async (ctx) => handleImageRoute(ctx, 'avatars'));
|
||||
app.get('/icons/:id/:filename', async (ctx) => handleImageRoute(ctx, 'icons'));
|
||||
app.get('/banners/:id/:filename', async (ctx) => handleImageRoute(ctx, 'banners'));
|
||||
app.get('/splashes/:id/:filename', async (ctx) => handleImageRoute(ctx, 'splashes'));
|
||||
app.get('/embed-splashes/:id/:filename', async (ctx) => handleImageRoute(ctx, 'embed-splashes'));
|
||||
app.get('/emojis/:id', async (ctx) => handleSimpleImageRoute(ctx, 'emojis'));
|
||||
app.get('/stickers/:id', handleStickerRoute);
|
||||
app.get('/guilds/:guild_id/users/:user_id/avatars/:filename', async (ctx) =>
|
||||
handleGuildMemberImageRoute(ctx, 'avatars'),
|
||||
);
|
||||
app.get('/guilds/:guild_id/users/:user_id/banners/:filename', async (ctx) =>
|
||||
handleGuildMemberImageRoute(ctx, 'banners'),
|
||||
);
|
||||
app.get('/attachments/:channel_id/:attachment_id/:filename', handleAttachmentsRoute);
|
||||
app.get('/themes/:id.css', handleThemeRequest);
|
||||
|
||||
app.get('/external/*', async (ctx) => {
|
||||
const fullPath = ctx.req.path;
|
||||
const externalIndex = fullPath.indexOf('/external/');
|
||||
if (externalIndex === -1) throw new HTTPException(400);
|
||||
const path = fullPath.substring(externalIndex + '/external/'.length);
|
||||
return processExternalMedia(ctx, path);
|
||||
});
|
||||
}
|
||||
|
||||
const errorHandler = createErrorHandler({
|
||||
includeStack: false,
|
||||
logError: (err: unknown) => {
|
||||
const isExpectedError = err instanceof Error && 'isExpected' in err && err.isExpected;
|
||||
|
||||
if (!(v.isValiError(err) || err instanceof SyntaxError || err instanceof HTTPException || isExpectedError)) {
|
||||
if (err instanceof Error) {
|
||||
captureException(err);
|
||||
}
|
||||
logger.error({err}, 'Unexpected error occurred');
|
||||
}
|
||||
},
|
||||
customHandler: (err: unknown, ctx) => {
|
||||
const isExpectedError = err instanceof Error && 'isExpected' in err && err.isExpected;
|
||||
|
||||
if (v.isValiError(err) || err instanceof SyntaxError) {
|
||||
return ctx.text('Bad Request', {status: 400});
|
||||
}
|
||||
if (err instanceof HTTPException) {
|
||||
return err.getResponse();
|
||||
}
|
||||
if (isExpectedError) {
|
||||
logger.warn({err}, 'Expected error occurred');
|
||||
return ctx.text('Bad Request', {status: 400});
|
||||
}
|
||||
logger.error({err}, 'Unhandled error occurred');
|
||||
return ctx.text('Internal Server Error', {status: 500});
|
||||
},
|
||||
});
|
||||
|
||||
app.onError(errorHandler);
|
||||
|
||||
const shutdown = async () => {
|
||||
logger.info('Shutting down media proxy');
|
||||
cloudflareEdgeIPService.shutdown();
|
||||
};
|
||||
|
||||
return {app, shutdown, services: exposedServices};
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
42
packages/media_proxy/src/lib/BinaryUtils.tsx
Normal file
42
packages/media_proxy/src/lib/BinaryUtils.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Readable, type Stream} from 'node:stream';
|
||||
import type {ReadableStream as WebReadableStream} from 'node:stream/web';
|
||||
|
||||
type BinaryLike = ArrayBufferView | ArrayBuffer;
|
||||
|
||||
export function toBodyData(value: BinaryLike): Uint8Array<ArrayBuffer> {
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return new Uint8Array(value);
|
||||
}
|
||||
|
||||
if (value.buffer instanceof ArrayBuffer) {
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
}
|
||||
|
||||
const copyBuffer = new ArrayBuffer(value.byteLength);
|
||||
const view = new Uint8Array(copyBuffer);
|
||||
view.set(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
|
||||
return new Uint8Array(copyBuffer);
|
||||
}
|
||||
|
||||
export function toWebReadableStream(stream: Stream): WebReadableStream<Uint8Array> {
|
||||
return Readable.toWeb(stream as Readable);
|
||||
}
|
||||
298
packages/media_proxy/src/lib/CloudflareEdgeIPService.tsx
Normal file
298
packages/media_proxy/src/lib/CloudflareEdgeIPService.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {isIP} from 'node:net';
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
|
||||
const CLOUDFLARE_IPV4_URL = 'https://www.cloudflare.com/ips-v4';
|
||||
const CLOUDFLARE_IPV6_URL = 'https://www.cloudflare.com/ips-v6';
|
||||
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
|
||||
|
||||
type IPFamily = 4 | 6;
|
||||
|
||||
interface ParsedIP {
|
||||
family: IPFamily;
|
||||
bytes: Buffer;
|
||||
}
|
||||
|
||||
interface CidrEntry {
|
||||
family: IPFamily;
|
||||
network: Buffer;
|
||||
prefixLength: number;
|
||||
}
|
||||
|
||||
type FetchFunction = (
|
||||
url: string,
|
||||
method: string,
|
||||
) => Promise<{status: number; stream: ReadableStream<Uint8Array> | null}>;
|
||||
|
||||
export class CloudflareEdgeIPService {
|
||||
private cidrs: Array<CidrEntry> = [];
|
||||
private refreshTimer?: NodeJS.Timeout | undefined;
|
||||
private logger: LoggerInterface;
|
||||
private fetchFn: FetchFunction;
|
||||
|
||||
constructor(logger: LoggerInterface, fetchFn: FetchFunction) {
|
||||
this.logger = logger;
|
||||
this.fetchFn = fetchFn;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.refreshNow();
|
||||
|
||||
this.refreshTimer = setInterval(() => {
|
||||
this.refreshNow().catch((error) => {
|
||||
this.logger.error({error}, 'Failed to refresh Cloudflare edge IP ranges; keeping existing ranges');
|
||||
});
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
this.refreshTimer.unref();
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
isFromCloudflareEdge(ip: string | undefined | null): boolean {
|
||||
if (!ip) return false;
|
||||
if (this.cidrs.length === 0) return false;
|
||||
|
||||
const parsed = parseIP(ip);
|
||||
if (!parsed) return false;
|
||||
|
||||
for (const cidr of this.cidrs) {
|
||||
if (cidr.family !== parsed.family) continue;
|
||||
if (ipInCidr(parsed, cidr)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async refreshNow(): Promise<void> {
|
||||
const [v4Ranges, v6Ranges] = await Promise.all([
|
||||
this.fetchRanges(CLOUDFLARE_IPV4_URL),
|
||||
this.fetchRanges(CLOUDFLARE_IPV6_URL),
|
||||
]);
|
||||
|
||||
const nextCidrs: Array<CidrEntry> = [];
|
||||
for (const range of [...v4Ranges, ...v6Ranges]) {
|
||||
const parsed = parseCIDR(range);
|
||||
if (!parsed) continue;
|
||||
nextCidrs.push(parsed);
|
||||
}
|
||||
|
||||
if (nextCidrs.length === 0) {
|
||||
throw new Error('Cloudflare edge IP list refresh returned no valid ranges');
|
||||
}
|
||||
|
||||
this.cidrs = nextCidrs;
|
||||
this.logger.info({count: this.cidrs.length}, 'Refreshed Cloudflare edge IP ranges');
|
||||
}
|
||||
|
||||
private async fetchRanges(url: string): Promise<Array<string>> {
|
||||
const response = await this.fetchFn(url, 'GET');
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to download Cloudflare edge IPs from ${url} (status ${response.status})`);
|
||||
}
|
||||
|
||||
const text = await CloudflareEdgeIPService.streamToString(response.stream);
|
||||
return text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
private static async streamToString(stream: ReadableStream<Uint8Array> | null): Promise<string> {
|
||||
if (!stream) {
|
||||
return '';
|
||||
}
|
||||
return new Response(stream).text();
|
||||
}
|
||||
}
|
||||
|
||||
function parseIP(ip: string): ParsedIP | null {
|
||||
const ipWithoutZone = ip.split('%', 1)[0]?.trim();
|
||||
if (!ipWithoutZone) return null;
|
||||
|
||||
const family = isIP(ipWithoutZone);
|
||||
if (!family) return null;
|
||||
|
||||
if (family === 6 && ipWithoutZone.includes('.')) {
|
||||
const lastColon = ipWithoutZone.lastIndexOf(':');
|
||||
const ipv4Part = ipWithoutZone.slice(lastColon + 1);
|
||||
const bytes = parseIPv4(ipv4Part);
|
||||
if (!bytes) return null;
|
||||
return {family: 4, bytes};
|
||||
}
|
||||
|
||||
if (family === 4) {
|
||||
const bytes = parseIPv4(ipWithoutZone);
|
||||
if (!bytes) return null;
|
||||
return {family: 4, bytes};
|
||||
}
|
||||
|
||||
const bytes = parseIPv6(ipWithoutZone);
|
||||
if (!bytes) return null;
|
||||
return {family: 6, bytes};
|
||||
}
|
||||
|
||||
function parseIPv4(ip: string): Buffer | null {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
const bytes = Buffer.alloc(4);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const part = parts[i];
|
||||
if (!part || !/^\d+$/.test(part)) return null;
|
||||
const octet = Number(part);
|
||||
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
|
||||
bytes[i] = octet;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseIPv6(ip: string): Buffer | null {
|
||||
const addr = ip.trim();
|
||||
|
||||
const hasDoubleColon = addr.includes('::');
|
||||
let headPart = '';
|
||||
let tailPart = '';
|
||||
|
||||
if (hasDoubleColon) {
|
||||
const parts = addr.split('::');
|
||||
if (parts.length !== 2) return null;
|
||||
headPart = parts[0] ?? '';
|
||||
tailPart = parts[1] ?? '';
|
||||
} else {
|
||||
headPart = addr;
|
||||
tailPart = '';
|
||||
}
|
||||
|
||||
const headGroups = headPart ? headPart.split(':').filter((g) => g.length > 0) : [];
|
||||
const tailGroups = tailPart ? tailPart.split(':').filter((g) => g.length > 0) : [];
|
||||
|
||||
if (!hasDoubleColon && headGroups.length !== 8) return null;
|
||||
|
||||
const totalGroups = headGroups.length + tailGroups.length;
|
||||
if (totalGroups > 8) return null;
|
||||
|
||||
const zerosToInsert = 8 - totalGroups;
|
||||
const groups: Array<number> = [];
|
||||
|
||||
for (const g of headGroups) {
|
||||
const value = parseInt(g, 16);
|
||||
if (!Number.isFinite(value) || value < 0 || value > 0xffff) return null;
|
||||
groups.push(value);
|
||||
}
|
||||
|
||||
for (let i = 0; i < zerosToInsert; i++) {
|
||||
groups.push(0);
|
||||
}
|
||||
|
||||
for (const g of tailGroups) {
|
||||
const value = parseInt(g, 16);
|
||||
if (!Number.isFinite(value) || value < 0 || value > 0xffff) return null;
|
||||
groups.push(value);
|
||||
}
|
||||
|
||||
if (groups.length !== 8) return null;
|
||||
|
||||
const bytes = Buffer.alloc(16);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const group = groups[i] ?? 0;
|
||||
bytes[i * 2] = (group >> 8) & 0xff;
|
||||
bytes[i * 2 + 1] = group & 0xff;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseCIDR(cidr: string): CidrEntry | null {
|
||||
const trimmed = cidr.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const slashIdx = trimmed.indexOf('/');
|
||||
if (slashIdx === -1) return null;
|
||||
|
||||
const ipPart = trimmed.slice(0, slashIdx).trim();
|
||||
const prefixPart = trimmed.slice(slashIdx + 1).trim();
|
||||
if (!ipPart || !prefixPart) return null;
|
||||
|
||||
const prefixLength = Number(prefixPart);
|
||||
if (!Number.isInteger(prefixLength) || prefixLength < 0) return null;
|
||||
|
||||
const parsedIP = parseIP(ipPart);
|
||||
if (!parsedIP) return null;
|
||||
|
||||
const maxPrefix = parsedIP.family === 4 ? 32 : 128;
|
||||
if (prefixLength > maxPrefix) return null;
|
||||
|
||||
const network = Buffer.from(parsedIP.bytes);
|
||||
const fullBytes = Math.floor(prefixLength / 8);
|
||||
const remainingBits = prefixLength % 8;
|
||||
|
||||
if (fullBytes < network.length) {
|
||||
if (remainingBits !== 0 && fullBytes < network.length) {
|
||||
const mask = (0xff << (8 - remainingBits)) & 0xff;
|
||||
const currentByte = network[fullBytes];
|
||||
if (currentByte !== undefined) {
|
||||
network[fullBytes] = currentByte & mask;
|
||||
}
|
||||
for (let i = fullBytes + 1; i < network.length; i++) {
|
||||
network[i] = 0;
|
||||
}
|
||||
} else {
|
||||
for (let i = fullBytes; i < network.length; i++) {
|
||||
network[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
family: parsedIP.family,
|
||||
network,
|
||||
prefixLength,
|
||||
};
|
||||
}
|
||||
|
||||
function ipInCidr(ip: ParsedIP, cidr: CidrEntry): boolean {
|
||||
if (ip.family !== cidr.family) return false;
|
||||
|
||||
const prefixLength = cidr.prefixLength;
|
||||
const bytesToCheck = Math.floor(prefixLength / 8);
|
||||
const remainingBits = prefixLength % 8;
|
||||
|
||||
for (let i = 0; i < bytesToCheck; i++) {
|
||||
if (ip.bytes[i] !== cidr.network[i]) return false;
|
||||
}
|
||||
|
||||
if (remainingBits > 0 && bytesToCheck < ip.bytes.length) {
|
||||
const mask = (0xff << (8 - remainingBits)) & 0xff;
|
||||
const idx = bytesToCheck;
|
||||
const ipByte = ip.bytes[idx];
|
||||
const cidrByte = cidr.network[idx];
|
||||
if (ipByte !== undefined && cidrByte !== undefined && (ipByte & mask) !== (cidrByte & mask)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
101
packages/media_proxy/src/lib/CodecValidation.tsx
Normal file
101
packages/media_proxy/src/lib/CodecValidation.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import {type FFprobeStream, ffprobe} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
|
||||
import {MEDIA_TYPES} from '@fluxer/media_proxy/src/lib/MediaTypes';
|
||||
import {temporaryFile} from 'tempy';
|
||||
|
||||
interface AudioStream extends FFprobeStream {
|
||||
codec_type: 'audio';
|
||||
}
|
||||
|
||||
function matchesCodecPattern(codec: string, patterns: Set<string>): boolean {
|
||||
if (!codec) return false;
|
||||
const lowerCodec = codec.toLowerCase();
|
||||
return (
|
||||
patterns.has(lowerCodec) ||
|
||||
Array.from(patterns).some((pattern) => {
|
||||
if (pattern.includes('*')) {
|
||||
return new RegExp(`^${pattern.replace('*', '.*')}$`).test(lowerCodec);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isProRes4444(codec: string): boolean {
|
||||
if (!codec) return false;
|
||||
const lowercaseCodec = codec.toLowerCase();
|
||||
return (
|
||||
matchesCodecPattern(lowercaseCodec, MEDIA_TYPES.VIDEO.bannedCodecs) ||
|
||||
(lowercaseCodec.includes('prores') && lowercaseCodec.includes('4444'))
|
||||
);
|
||||
}
|
||||
|
||||
export function createCodecValidator(logger: LoggerInterface) {
|
||||
const validateCodecs = async (buffer: Buffer, filename: string): Promise<boolean> => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
if (!ext) return false;
|
||||
|
||||
const tempPath = temporaryFile({extension: ext});
|
||||
|
||||
try {
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
const probeData = await ffprobe(tempPath);
|
||||
|
||||
if (filename.toLowerCase().endsWith('.ogg')) {
|
||||
const hasVideo = probeData.streams?.some((stream) => stream.codec_type === 'video');
|
||||
if (hasVideo) return false;
|
||||
|
||||
const audioStream = probeData.streams?.find((stream): stream is AudioStream => stream.codec_type === 'audio');
|
||||
return Boolean(audioStream?.codec_name && ['opus', 'vorbis'].includes(audioStream.codec_name));
|
||||
}
|
||||
|
||||
const validateStream = (stream: FFprobeStream, type: 'video' | 'audio') => {
|
||||
const codec = stream.codec_name || '';
|
||||
if (type === 'video') {
|
||||
if (isProRes4444(codec)) return false;
|
||||
return matchesCodecPattern(codec, MEDIA_TYPES.VIDEO.codecs);
|
||||
}
|
||||
return matchesCodecPattern(codec, MEDIA_TYPES.AUDIO.codecs);
|
||||
};
|
||||
|
||||
for (const stream of probeData.streams || []) {
|
||||
if (stream.codec_type === 'video' || stream.codec_type === 'audio') {
|
||||
if (!validateStream(stream, stream.codec_type)) {
|
||||
logger.debug({filename, codec: stream.codec_name ?? 'unknown'}, `Unsupported ${stream.codec_type} codec`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error({error: err, filename}, 'Failed to validate media codecs');
|
||||
return false;
|
||||
} finally {
|
||||
await fs.unlink(tempPath).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
return {validateCodecs};
|
||||
}
|
||||
|
||||
export type CodecValidator = ReturnType<typeof createCodecValidator>;
|
||||
307
packages/media_proxy/src/lib/FFmpegUtils.tsx
Normal file
307
packages/media_proxy/src/lib/FFmpegUtils.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {execFile} from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import {promisify} from 'node:util';
|
||||
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
|
||||
import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
|
||||
import {temporaryFile} from 'tempy';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
import {
|
||||
DEFAULT_FFPROBE_TIMEOUT_MS,
|
||||
DEFAULT_FRAME_EXTRACTION_TIMEOUT_MS,
|
||||
DEFAULT_THUMBNAIL_TIMEOUT_MS,
|
||||
} from '@fluxer/constants/src/Timeouts';
|
||||
|
||||
export class FFmpegTimeoutError extends Error {
|
||||
readonly operation: string;
|
||||
readonly timeoutMs: number;
|
||||
|
||||
constructor(operation: string, timeoutMs: number) {
|
||||
super(`FFmpeg operation '${operation}' timed out after ${timeoutMs}ms`);
|
||||
this.name = 'FFmpegTimeoutError';
|
||||
this.operation = operation;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
function isTimeoutError(error: unknown): boolean {
|
||||
if (error instanceof Error && 'killed' in error) {
|
||||
return (error as {killed: boolean}).killed === true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface FFprobeStream {
|
||||
codec_name?: string;
|
||||
codec_type?: string;
|
||||
}
|
||||
|
||||
interface FFprobeFormat {
|
||||
format_name?: string;
|
||||
duration?: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
interface FFprobeResult {
|
||||
streams?: Array<FFprobeStream>;
|
||||
format?: FFprobeFormat;
|
||||
}
|
||||
|
||||
function parseProbeOutput(stdout: string): FFprobeResult {
|
||||
const parsed = JSON.parse(stdout) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('Invalid ffprobe output');
|
||||
}
|
||||
return parsed as FFprobeResult;
|
||||
}
|
||||
|
||||
export async function ffprobe(path: string, timeoutMs = DEFAULT_FFPROBE_TIMEOUT_MS): Promise<FFprobeResult> {
|
||||
try {
|
||||
const {stdout} = await execFileAsync(
|
||||
'ffprobe',
|
||||
['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', path],
|
||||
{timeout: timeoutMs},
|
||||
);
|
||||
return parseProbeOutput(stdout);
|
||||
} catch (error) {
|
||||
if (isTimeoutError(error)) {
|
||||
throw new FFmpegTimeoutError('ffprobe', timeoutMs);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasVideoStream(path: string): Promise<boolean> {
|
||||
const probeResult = await ffprobe(path);
|
||||
return probeResult.streams?.some((stream) => stream.codec_type === 'video') ?? false;
|
||||
}
|
||||
|
||||
export function createFFmpegUtils(options?: {
|
||||
metrics?: MetricsInterface | undefined;
|
||||
tracing?: TracingInterface | undefined;
|
||||
}) {
|
||||
const {metrics, tracing} = options ?? {};
|
||||
|
||||
const createThumbnail = async (videoPath: string, timeoutMs = DEFAULT_THUMBNAIL_TIMEOUT_MS): Promise<string> => {
|
||||
const fn = async () => {
|
||||
const startTime = Date.now();
|
||||
const hasVideo = await hasVideoStream(videoPath);
|
||||
if (!hasVideo) {
|
||||
throw new Error('File does not contain a video stream');
|
||||
}
|
||||
const thumbnailPath = temporaryFile({extension: 'jpg'});
|
||||
try {
|
||||
await execFileAsync('ffmpeg', ['-i', videoPath, '-vf', 'select=eq(n\\,0)', '-vframes', '1', thumbnailPath], {
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTimeoutError(error)) {
|
||||
throw new FFmpegTimeoutError('thumbnail', timeoutMs);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
metrics?.histogram({
|
||||
name: 'media_proxy.thumbnail.latency',
|
||||
dimensions: {},
|
||||
valueMs: duration,
|
||||
});
|
||||
|
||||
return thumbnailPath;
|
||||
};
|
||||
|
||||
if (tracing) {
|
||||
return tracing.withSpan(
|
||||
{
|
||||
name: 'video.thumbnail.create',
|
||||
attributes: {
|
||||
'video.path': videoPath,
|
||||
},
|
||||
},
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
||||
return fn();
|
||||
};
|
||||
|
||||
return {createThumbnail};
|
||||
}
|
||||
|
||||
export type FFmpegUtilsType = ReturnType<typeof createFFmpegUtils>;
|
||||
|
||||
export interface MediaProbeInfo {
|
||||
durationSeconds: number | null;
|
||||
hasVideoStream: boolean;
|
||||
}
|
||||
|
||||
export async function getMediaProbeInfo(path: string): Promise<MediaProbeInfo> {
|
||||
const probeResult = await ffprobe(path);
|
||||
const durationSeconds =
|
||||
probeResult.format?.duration && Number.isFinite(Number.parseFloat(probeResult.format.duration))
|
||||
? Number.parseFloat(probeResult.format.duration)
|
||||
: null;
|
||||
const hasVideo = probeResult.streams?.some((stream) => stream.codec_type === 'video') ?? false;
|
||||
return {
|
||||
durationSeconds,
|
||||
hasVideoStream: hasVideo,
|
||||
};
|
||||
}
|
||||
|
||||
function clampToDuration(value: number, duration: number | null): number {
|
||||
if (!Number.isFinite(duration as number) || (duration ?? 0) <= 0) {
|
||||
return Math.max(0, value);
|
||||
}
|
||||
const maxValue = Math.max(0, (duration as number) - 0.001);
|
||||
if (maxValue <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(value, maxValue));
|
||||
}
|
||||
|
||||
function applyJitter(value: number, duration: number | null, jitterPercent: number): number {
|
||||
const jitterRadius = Math.max(0.05, Math.abs(value) * jitterPercent);
|
||||
const jittered = value + (Math.random() * 2 - 1) * jitterRadius;
|
||||
return clampToDuration(jittered, duration);
|
||||
}
|
||||
|
||||
export function computeFrameSampleTimestamps(durationSeconds: number | null, jitterPercent = 0.1): Array<number> {
|
||||
const actualDuration =
|
||||
Number.isFinite(durationSeconds as number) && (durationSeconds as number) > 0 ? (durationSeconds as number) : null;
|
||||
const fallbackDuration = actualDuration ?? 1;
|
||||
|
||||
const startBase = clampToDuration(Math.min(2, Math.max(1, fallbackDuration * 0.1 + 0.5)), actualDuration);
|
||||
const middleBase = clampToDuration(fallbackDuration / 2, actualDuration);
|
||||
const endCandidate = fallbackDuration > 2 ? fallbackDuration - 1 : fallbackDuration * 0.95;
|
||||
const minEnd = startBase + 0.5;
|
||||
const endBase = clampToDuration(Math.max(minEnd, endCandidate), actualDuration);
|
||||
|
||||
return [
|
||||
applyJitter(startBase, actualDuration, jitterPercent),
|
||||
applyJitter(middleBase, actualDuration, jitterPercent),
|
||||
applyJitter(endBase, actualDuration, jitterPercent),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: number): number {
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
export function createFrameExtractor(options?: {tracing?: TracingInterface | undefined}) {
|
||||
const {tracing} = options ?? {};
|
||||
|
||||
const extractFrameAt = async (
|
||||
mediaPath: string,
|
||||
timestampSeconds: number,
|
||||
timeoutMs = DEFAULT_FRAME_EXTRACTION_TIMEOUT_MS,
|
||||
): Promise<string> => {
|
||||
const fn = async () => {
|
||||
const timestamp = normalizeTimestamp(timestampSeconds);
|
||||
const framePath = temporaryFile({extension: 'jpg'});
|
||||
try {
|
||||
await execFileAsync(
|
||||
'ffmpeg',
|
||||
[
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-ss',
|
||||
timestamp.toFixed(3),
|
||||
'-i',
|
||||
mediaPath,
|
||||
'-frames:v',
|
||||
'1',
|
||||
'-q:v',
|
||||
'2',
|
||||
'-y',
|
||||
framePath,
|
||||
],
|
||||
{timeout: timeoutMs},
|
||||
);
|
||||
} catch (error) {
|
||||
if (isTimeoutError(error)) {
|
||||
throw new FFmpegTimeoutError('frame_extraction', timeoutMs);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(framePath);
|
||||
} catch {
|
||||
throw new Error(`Frame extraction produced no output at timestamp ${timestamp}`);
|
||||
}
|
||||
|
||||
return framePath;
|
||||
};
|
||||
|
||||
if (tracing) {
|
||||
return tracing.withSpan(
|
||||
{
|
||||
name: 'video.frame.extract',
|
||||
attributes: {
|
||||
'video.path': mediaPath,
|
||||
'video.timestamp': String(timestampSeconds),
|
||||
},
|
||||
},
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
||||
return fn();
|
||||
};
|
||||
|
||||
const extractFramesAtTimes = async (mediaPath: string, timestamps: Array<number>): Promise<Array<string>> => {
|
||||
if (timestamps.length === 0) {
|
||||
timestamps = [0];
|
||||
}
|
||||
|
||||
const extractionPromises = timestamps.map((timestamp) => {
|
||||
const clampedTimestamp = Math.max(0, timestamp);
|
||||
return extractFrameAt(mediaPath, clampedTimestamp);
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(extractionPromises);
|
||||
|
||||
const frames: Array<string> = [];
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
frames.push(result.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (frames.length === 0) {
|
||||
const firstError = results.find((r) => r.status === 'rejected');
|
||||
if (firstError && firstError.status === 'rejected') {
|
||||
throw firstError.reason instanceof Error ? firstError.reason : new Error(String(firstError.reason));
|
||||
}
|
||||
throw new Error('No frames could be extracted');
|
||||
}
|
||||
|
||||
return frames;
|
||||
};
|
||||
|
||||
return {extractFrameAt, extractFramesAtTimes};
|
||||
}
|
||||
|
||||
export type FrameExtractor = ReturnType<typeof createFrameExtractor>;
|
||||
67
packages/media_proxy/src/lib/HttpUtils.tsx
Normal file
67
packages/media_proxy/src/lib/HttpUtils.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Context} from 'hono';
|
||||
|
||||
export function parseRange(rangeHeader: string | null, fileSize: number) {
|
||||
if (!rangeHeader) return null;
|
||||
const matches = rangeHeader.match(/bytes=(\d*)-(\d*)/);
|
||||
if (!matches) return null;
|
||||
|
||||
const start = matches[1] ? Number.parseInt(matches[1], 10) : 0;
|
||||
const end = matches[2] ? Number.parseInt(matches[2], 10) : fileSize - 1;
|
||||
|
||||
return start >= fileSize || end >= fileSize || start > end ? null : {start, end};
|
||||
}
|
||||
|
||||
export function setHeaders(
|
||||
ctx: Context,
|
||||
size: number,
|
||||
contentType: string,
|
||||
range: {start: number; end: number} | null,
|
||||
lastModified?: Date,
|
||||
) {
|
||||
const isStreamableMedia = contentType.startsWith('video/') || contentType.startsWith('audio/');
|
||||
|
||||
const headers = {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': isStreamableMedia
|
||||
? 'public, max-age=31536000, no-transform, immutable'
|
||||
: 'public, max-age=31536000',
|
||||
'Content-Type': contentType,
|
||||
Date: new Date().toUTCString(),
|
||||
Expires: new Date(Date.now() + 31536000000).toUTCString(),
|
||||
'Last-Modified': lastModified?.toUTCString() ?? new Date().toUTCString(),
|
||||
Vary: 'Accept-Encoding, Range',
|
||||
};
|
||||
|
||||
Object.entries(headers).forEach(([k, v]) => {
|
||||
ctx.header(k, v);
|
||||
});
|
||||
|
||||
if (range) {
|
||||
const length = range.end - range.start + 1;
|
||||
ctx.status(206);
|
||||
ctx.header('Content-Length', length.toString());
|
||||
ctx.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`);
|
||||
} else {
|
||||
ctx.header('Content-Length', size.toString());
|
||||
}
|
||||
}
|
||||
141
packages/media_proxy/src/lib/ImageProcessing.tsx
Normal file
141
packages/media_proxy/src/lib/ImageProcessing.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
|
||||
import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
|
||||
import sharp from 'sharp';
|
||||
import {rgbaToThumbHash} from 'thumbhash';
|
||||
|
||||
export async function generatePlaceholder(imageBuffer: Buffer): Promise<string> {
|
||||
try {
|
||||
const metadata = await sharp(imageBuffer).metadata();
|
||||
const width = metadata.width ?? 1;
|
||||
const height = metadata.height ?? 1;
|
||||
|
||||
if (width < 3 || height < 3) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let pipeline = sharp(imageBuffer);
|
||||
|
||||
if (width > 10 && height > 10) {
|
||||
const blurSigma = Math.min(10, Math.floor(Math.min(width, height) / 10));
|
||||
if (blurSigma >= 0.3) {
|
||||
pipeline = pipeline.blur(blurSigma);
|
||||
}
|
||||
}
|
||||
|
||||
const {data, info} = await pipeline
|
||||
.resize(100, 100, {fit: 'inside', withoutEnlargement: true})
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({resolveWithObject: true});
|
||||
|
||||
if (data.length !== info.width * info.height * 4) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const placeholder = rgbaToThumbHash(info.width, info.height, data);
|
||||
return Buffer.from(placeholder).toString('base64');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const ANIMATED_OUTPUT_FORMATS = new Set(['gif', 'webp', 'avif', 'png', 'apng']);
|
||||
|
||||
export function createImageProcessor(options?: {
|
||||
metrics?: MetricsInterface | undefined;
|
||||
tracing?: TracingInterface | undefined;
|
||||
}) {
|
||||
const {metrics, tracing} = options ?? {};
|
||||
|
||||
const processImage = async (opts: {
|
||||
buffer: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
format: string;
|
||||
quality: string;
|
||||
animated: boolean;
|
||||
}): Promise<Buffer> => {
|
||||
const fn = async () => {
|
||||
const startTime = Date.now();
|
||||
const metadata = await sharp(opts.buffer).metadata();
|
||||
|
||||
const resizeWidth = Math.min(opts.width, metadata.width || 0);
|
||||
const resizeHeight = Math.min(opts.height, metadata.height || 0);
|
||||
const targetFormat = opts.format.toLowerCase();
|
||||
if (!targetFormat) {
|
||||
throw new Error('Target image format is required');
|
||||
}
|
||||
|
||||
const supportsAnimation = ANIMATED_OUTPUT_FORMATS.has(targetFormat);
|
||||
const shouldAnimate = opts.animated && supportsAnimation;
|
||||
|
||||
const result = await sharp(opts.buffer, {
|
||||
animated: shouldAnimate,
|
||||
})
|
||||
.resize(resizeWidth, resizeHeight, {
|
||||
fit: 'cover',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.toFormat(targetFormat as keyof sharp.FormatEnum, {
|
||||
quality: opts.quality === 'high' ? 80 : opts.quality === 'low' ? 20 : 100,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
metrics?.histogram({
|
||||
name: 'media_proxy.transform.latency',
|
||||
dimensions: {format: targetFormat, quality: opts.quality},
|
||||
valueMs: duration,
|
||||
});
|
||||
|
||||
metrics?.counter({
|
||||
name: 'media_proxy.transform.bytes',
|
||||
dimensions: {format: targetFormat, quality: opts.quality},
|
||||
value: result.length,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
if (tracing) {
|
||||
return tracing.withSpan(
|
||||
{
|
||||
name: 'image.transform',
|
||||
attributes: {
|
||||
'image.width': opts.width,
|
||||
'image.height': opts.height,
|
||||
'image.format': opts.format,
|
||||
'image.quality': opts.quality,
|
||||
'image.animated': String(opts.animated),
|
||||
},
|
||||
},
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
||||
return fn();
|
||||
};
|
||||
|
||||
return {processImage};
|
||||
}
|
||||
|
||||
export type ImageProcessor = ReturnType<typeof createImageProcessor>;
|
||||
64
packages/media_proxy/src/lib/InMemoryCoalescer.tsx
Normal file
64
packages/media_proxy/src/lib/InMemoryCoalescer.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
|
||||
import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
|
||||
|
||||
export class InMemoryCoalescer {
|
||||
private pending = new Map<string, Promise<unknown>>();
|
||||
private metrics: MetricsInterface | undefined;
|
||||
private tracing: TracingInterface | undefined;
|
||||
|
||||
constructor(options?: {metrics?: MetricsInterface | undefined; tracing?: TracingInterface | undefined}) {
|
||||
this.metrics = options?.metrics;
|
||||
this.tracing = options?.tracing;
|
||||
}
|
||||
|
||||
async coalesce<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const existing = this.pending.get(key) as Promise<T> | undefined;
|
||||
if (existing) {
|
||||
this.metrics?.counter({name: 'media_proxy.cache.hit'});
|
||||
this.tracing?.addSpanEvent('cache.hit', {cache_key: key});
|
||||
return existing;
|
||||
}
|
||||
|
||||
this.metrics?.counter({name: 'media_proxy.cache.miss'});
|
||||
this.tracing?.addSpanEvent('cache.miss', {cache_key: key});
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
if (this.tracing) {
|
||||
return await this.tracing.withSpan(
|
||||
{
|
||||
name: 'cache.compute',
|
||||
attributes: {cache_key: key},
|
||||
},
|
||||
async () => fn(),
|
||||
);
|
||||
}
|
||||
return await fn();
|
||||
} finally {
|
||||
this.pending.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
this.pending.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
126
packages/media_proxy/src/lib/MediaTransformService.tsx
Normal file
126
packages/media_proxy/src/lib/MediaTransformService.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import type {FFmpegUtilsType} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
|
||||
import type {ImageProcessor} from '@fluxer/media_proxy/src/lib/ImageProcessing';
|
||||
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
|
||||
import sharp from 'sharp';
|
||||
import {temporaryFile} from 'tempy';
|
||||
|
||||
interface TransformResult {
|
||||
data: Buffer;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
interface ImageTransformOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
quality: string;
|
||||
animated: boolean;
|
||||
fallbackContentType: string;
|
||||
}
|
||||
|
||||
interface VideoThumbnailTransformOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
format: string;
|
||||
quality: string;
|
||||
}
|
||||
|
||||
interface MediaTransformServiceDeps {
|
||||
imageProcessor: ImageProcessor;
|
||||
ffmpegUtils: FFmpegUtilsType;
|
||||
mimeTypeUtils: MimeTypeUtils;
|
||||
}
|
||||
|
||||
export function createMediaTransformService(deps: MediaTransformServiceDeps) {
|
||||
const {imageProcessor, ffmpegUtils, mimeTypeUtils} = deps;
|
||||
const {processImage} = imageProcessor;
|
||||
const {createThumbnail} = ffmpegUtils;
|
||||
const {getMimeType} = mimeTypeUtils;
|
||||
|
||||
async function transformImage(buffer: Buffer, options: ImageTransformOptions): Promise<TransformResult> {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
const targetWidth = options.width ? Math.min(options.width, metadata.width || 0) : metadata.width || 0;
|
||||
const targetHeight = options.height ? Math.min(options.height, metadata.height || 0) : metadata.height || 0;
|
||||
|
||||
const outputFormat = (options.format || metadata.format?.toLowerCase() || '').toLowerCase();
|
||||
const image = await processImage({
|
||||
buffer,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
format: outputFormat,
|
||||
quality: options.quality,
|
||||
animated: options.animated,
|
||||
});
|
||||
|
||||
const contentType = options.format
|
||||
? getMimeType(Buffer.from(''), `image.${options.format}`) || 'application/octet-stream'
|
||||
: options.fallbackContentType;
|
||||
|
||||
return {data: image, contentType};
|
||||
}
|
||||
|
||||
async function transformVideoThumbnail(
|
||||
buffer: Buffer,
|
||||
mimeType: string,
|
||||
options: VideoThumbnailTransformOptions,
|
||||
): Promise<TransformResult> {
|
||||
const ext = mimeType.split('/')[1] ?? 'bin';
|
||||
const tempPath = temporaryFile({extension: ext});
|
||||
const tempFiles: Array<string> = [tempPath];
|
||||
|
||||
try {
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
|
||||
const thumbnailPath = await createThumbnail(tempPath);
|
||||
tempFiles.push(thumbnailPath);
|
||||
|
||||
const thumbnailData = await fs.readFile(thumbnailPath);
|
||||
const thumbMeta = await sharp(thumbnailData).metadata();
|
||||
|
||||
const targetWidth = options.width ? Math.min(options.width, thumbMeta.width || 0) : thumbMeta.width || 0;
|
||||
const targetHeight = options.height ? Math.min(options.height, thumbMeta.height || 0) : thumbMeta.height || 0;
|
||||
|
||||
const processedThumbnail = await processImage({
|
||||
buffer: thumbnailData,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
format: options.format,
|
||||
quality: options.quality,
|
||||
animated: false,
|
||||
});
|
||||
|
||||
const contentType = getMimeType(Buffer.from(''), `image.${options.format}`);
|
||||
if (!contentType) {
|
||||
throw new Error('Unsupported image format');
|
||||
}
|
||||
|
||||
return {data: processedThumbnail, contentType};
|
||||
} finally {
|
||||
await Promise.all(tempFiles.map((f) => fs.unlink(f).catch(() => {})));
|
||||
}
|
||||
}
|
||||
|
||||
return {transformImage, transformVideoThumbnail};
|
||||
}
|
||||
|
||||
export type MediaTransformService = ReturnType<typeof createMediaTransformService>;
|
||||
91
packages/media_proxy/src/lib/MediaTypes.tsx
Normal file
91
packages/media_proxy/src/lib/MediaTypes.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const MEDIA_TYPES = {
|
||||
IMAGE: {
|
||||
extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'],
|
||||
mimes: {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
svg: 'image/svg+xml',
|
||||
},
|
||||
},
|
||||
VIDEO: {
|
||||
extensions: ['mp4', 'webm', 'mov', 'mkv', 'avi'],
|
||||
mimes: {
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
mkv: 'video/x-matroska',
|
||||
avi: 'video/x-msvideo',
|
||||
},
|
||||
codecs: new Set([
|
||||
'h264',
|
||||
'avc1',
|
||||
'hevc',
|
||||
'hev1',
|
||||
'hvc1',
|
||||
'h265',
|
||||
'vp8',
|
||||
'vp9',
|
||||
'av1',
|
||||
'av01',
|
||||
'theora',
|
||||
'mpeg4',
|
||||
'mpeg2video',
|
||||
'mpeg1video',
|
||||
'h263',
|
||||
'prores',
|
||||
'mjpeg',
|
||||
'wmv1',
|
||||
'wmv2',
|
||||
'wmv3',
|
||||
'vc1',
|
||||
'msmpeg4v3',
|
||||
]),
|
||||
bannedCodecs: new Set(['prores_4444', 'prores_4444xq', 'apch', 'apcn', 'apcs', 'apco', 'ap4h', 'ap4x']),
|
||||
},
|
||||
AUDIO: {
|
||||
extensions: ['mp3', 'wav', 'flac', 'opus', 'aac', 'm4a', 'ogg'],
|
||||
mimes: {
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
flac: 'audio/flac',
|
||||
opus: 'audio/opus',
|
||||
aac: 'audio/aac',
|
||||
m4a: 'audio/mp4',
|
||||
ogg: 'audio/ogg',
|
||||
},
|
||||
codecs: new Set(['aac', 'mp4a', 'mp3', 'opus', 'vorbis', 'flac', 'pcm_s16le', 'pcm_s24le', 'pcm_f32le']),
|
||||
},
|
||||
};
|
||||
|
||||
export const SUPPORTED_EXTENSIONS = {
|
||||
...MEDIA_TYPES.IMAGE.mimes,
|
||||
...MEDIA_TYPES.VIDEO.mimes,
|
||||
...MEDIA_TYPES.AUDIO.mimes,
|
||||
};
|
||||
|
||||
export const SUPPORTED_MIME_TYPES = new Set(Object.values(SUPPORTED_EXTENSIONS));
|
||||
|
||||
export type SupportedExtension = keyof typeof SUPPORTED_EXTENSIONS;
|
||||
170
packages/media_proxy/src/lib/MediaValidation.tsx
Normal file
170
packages/media_proxy/src/lib/MediaValidation.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import type {CodecValidator} from '@fluxer/media_proxy/src/lib/CodecValidation';
|
||||
import type {FFmpegUtilsType} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
|
||||
import {ffprobe} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
|
||||
import {generatePlaceholder} from '@fluxer/media_proxy/src/lib/ImageProcessing';
|
||||
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
|
||||
import sharp from 'sharp';
|
||||
import {temporaryFile} from 'tempy';
|
||||
|
||||
interface ImageMetadata {
|
||||
format: string;
|
||||
size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
placeholder: string;
|
||||
animated: boolean;
|
||||
}
|
||||
|
||||
interface VideoMetadata {
|
||||
format: string;
|
||||
size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
interface AudioMetadata {
|
||||
format: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
type MediaMetadata = ImageMetadata | VideoMetadata | AudioMetadata;
|
||||
|
||||
export interface MediaValidator {
|
||||
validateMedia: (buffer: Buffer, filename: string) => Promise<string>;
|
||||
processMetadata: (mimeType: string, buffer: Buffer) => Promise<MediaMetadata>;
|
||||
}
|
||||
|
||||
export function createMediaValidator(
|
||||
mimeTypeUtils: MimeTypeUtils,
|
||||
codecValidator: CodecValidator,
|
||||
ffmpegUtils: FFmpegUtilsType,
|
||||
): MediaValidator {
|
||||
const {getMimeType, generateFilename, getMediaCategory} = mimeTypeUtils;
|
||||
const {validateCodecs} = codecValidator;
|
||||
const {createThumbnail} = ffmpegUtils;
|
||||
|
||||
async function validateMedia(buffer: Buffer, filename: string): Promise<string> {
|
||||
const mimeType = getMimeType(buffer, filename);
|
||||
|
||||
if (!mimeType) {
|
||||
throw new Error('Unsupported file format');
|
||||
}
|
||||
|
||||
const mediaType = getMediaCategory(mimeType);
|
||||
|
||||
if (!mediaType) {
|
||||
throw new Error('Invalid media type');
|
||||
}
|
||||
|
||||
if (mediaType !== 'image') {
|
||||
const validationFilename = filename.includes('.') ? filename : generateFilename(mimeType, filename);
|
||||
const isValid = await validateCodecs(buffer, validationFilename);
|
||||
if (!isValid) {
|
||||
throw new Error('File contains unsupported or non-web-compatible codecs');
|
||||
}
|
||||
}
|
||||
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
function toNumericField(value: string | number | undefined): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
async function processMetadata(mimeType: string, buffer: Buffer): Promise<MediaMetadata> {
|
||||
const mediaType = getMediaCategory(mimeType);
|
||||
|
||||
if (mediaType === 'image') {
|
||||
const {
|
||||
format: rawFormat = '',
|
||||
size = 0,
|
||||
width = 0,
|
||||
height: rawHeight = 0,
|
||||
pages,
|
||||
} = await sharp(buffer, {
|
||||
animated: true,
|
||||
}).metadata();
|
||||
|
||||
const animated = pages ? pages > 1 : false;
|
||||
const height = animated ? Math.round(rawHeight / pages!) : rawHeight;
|
||||
|
||||
if (width > 9500 || height > 9500) {
|
||||
throw new Error('Image dimensions too large');
|
||||
}
|
||||
|
||||
return {
|
||||
format: rawFormat.toLowerCase(),
|
||||
size,
|
||||
width,
|
||||
height,
|
||||
placeholder: await generatePlaceholder(buffer),
|
||||
animated,
|
||||
} satisfies ImageMetadata;
|
||||
}
|
||||
|
||||
const ext = mimeType.split('/')[1] ?? 'bin';
|
||||
const tempPath = temporaryFile({extension: ext});
|
||||
const tempFiles: Array<string> = [tempPath];
|
||||
|
||||
try {
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
|
||||
if (mediaType === 'video') {
|
||||
const thumbnailPath = await createThumbnail(tempPath);
|
||||
tempFiles.push(thumbnailPath);
|
||||
|
||||
const thumbnailData = await fs.readFile(thumbnailPath);
|
||||
const {width = 0, height = 0} = await sharp(thumbnailData).metadata();
|
||||
const probeData = await ffprobe(tempPath);
|
||||
|
||||
return {
|
||||
format: (probeData.format?.format_name || '').toLowerCase(),
|
||||
size: toNumericField(probeData.format?.size),
|
||||
width,
|
||||
height,
|
||||
duration: Math.ceil(Number(probeData.format?.duration || 0)),
|
||||
placeholder: await generatePlaceholder(thumbnailData),
|
||||
} satisfies VideoMetadata;
|
||||
}
|
||||
|
||||
if (mediaType === 'audio') {
|
||||
const probeData = await ffprobe(tempPath);
|
||||
return {
|
||||
format: (probeData.format?.format_name || '').toLowerCase(),
|
||||
size: toNumericField(probeData.format?.size),
|
||||
duration: Math.ceil(Number(probeData.format?.duration || 0)),
|
||||
} satisfies AudioMetadata;
|
||||
}
|
||||
|
||||
throw new Error('Unsupported media type');
|
||||
} finally {
|
||||
await Promise.all(tempFiles.map((f) => fs.unlink(f).catch(() => {})));
|
||||
}
|
||||
}
|
||||
|
||||
return {validateMedia, processMetadata};
|
||||
}
|
||||
78
packages/media_proxy/src/lib/MimeTypeUtils.tsx
Normal file
78
packages/media_proxy/src/lib/MimeTypeUtils.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import {
|
||||
SUPPORTED_EXTENSIONS,
|
||||
SUPPORTED_MIME_TYPES,
|
||||
type SupportedExtension,
|
||||
} from '@fluxer/media_proxy/src/lib/MediaTypes';
|
||||
import {getContentTypeFromFilename} from '@fluxer/mime_utils/src/ContentTypeUtils';
|
||||
import {filetypeinfo} from 'magic-bytes.js';
|
||||
|
||||
export function createMimeTypeUtils(logger: LoggerInterface) {
|
||||
const getMimeType = (buffer: Buffer, filename?: string): string | null => {
|
||||
if (filename) {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
if (ext && ext in SUPPORTED_EXTENSIONS) {
|
||||
const mimeType = SUPPORTED_EXTENSIONS[ext as SupportedExtension];
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fileInfo = filetypeinfo(buffer);
|
||||
if (fileInfo?.[0]?.mime && SUPPORTED_MIME_TYPES.has(fileInfo[0].mime)) {
|
||||
return fileInfo[0].mime;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Failed to detect file type using magic bytes');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getContentType = (filename: string): string => {
|
||||
return getContentTypeFromFilename(filename);
|
||||
};
|
||||
|
||||
const generateFilename = (mimeType: string, originalFilename?: string): string => {
|
||||
const baseName = originalFilename ? originalFilename.split('.')[0] : 'file';
|
||||
const mimeToExt = Object.entries(SUPPORTED_EXTENSIONS).reduce(
|
||||
(acc, [ext, mime]) => {
|
||||
acc[mime] = ext;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
const extension = mimeToExt[mimeType];
|
||||
if (!extension) throw new Error(`Unsupported MIME type: ${mimeType}`);
|
||||
return `${baseName}.${extension}`;
|
||||
};
|
||||
|
||||
const getMediaCategory = (mimeType: string): string | null => {
|
||||
const category = mimeType.split('/')[0] ?? '';
|
||||
return ['image', 'video', 'audio'].includes(category) ? category : null;
|
||||
};
|
||||
|
||||
return {getMimeType, generateFilename, getMediaCategory, getContentType};
|
||||
}
|
||||
|
||||
export type MimeTypeUtils = ReturnType<typeof createMimeTypeUtils>;
|
||||
120
packages/media_proxy/src/lib/NSFWDetectionService.tsx
Normal file
120
packages/media_proxy/src/lib/NSFWDetectionService.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 fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import * as ort from 'onnxruntime-node';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const MODEL_SIZE = 224;
|
||||
|
||||
interface NSFWCheckResult {
|
||||
isNSFW: boolean;
|
||||
probability: number;
|
||||
predictions?: {
|
||||
drawing: number;
|
||||
hentai: number;
|
||||
neutral: number;
|
||||
porn: number;
|
||||
sexy: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class NSFWDetectionService {
|
||||
private session: ort.InferenceSession | null = null;
|
||||
private readonly NSFW_THRESHOLD = 0.85;
|
||||
private modelPath: string;
|
||||
|
||||
constructor(options?: {modelPath?: string | undefined; nodeEnv?: string | undefined}) {
|
||||
const nodeEnv = options?.nodeEnv ?? 'production';
|
||||
this.modelPath =
|
||||
options?.modelPath ??
|
||||
(nodeEnv === 'production' ? '/opt/data/model.onnx' : path.join(process.cwd(), 'data', 'model.onnx'));
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const modelBuffer = await fs.readFile(this.modelPath);
|
||||
this.session = await ort.InferenceSession.create(modelBuffer);
|
||||
}
|
||||
|
||||
async checkNSFW(filePath: string): Promise<NSFWCheckResult> {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
return this.checkNSFWBuffer(buffer);
|
||||
}
|
||||
|
||||
async checkNSFWBuffer(buffer: Buffer): Promise<NSFWCheckResult> {
|
||||
if (!this.session) {
|
||||
throw new Error('NSFW Detection service not initialized');
|
||||
}
|
||||
|
||||
const processedImage = await this.preprocessImage(buffer);
|
||||
const tensor = new ort.Tensor('float32', processedImage, [1, MODEL_SIZE, MODEL_SIZE, 3]);
|
||||
|
||||
const feeds = {input: tensor};
|
||||
const results = await this.session.run(feeds);
|
||||
|
||||
const outputTensor = results['prediction'];
|
||||
if (!outputTensor || !outputTensor.data) {
|
||||
throw new Error('ONNX model output tensor data is undefined');
|
||||
}
|
||||
|
||||
const predictions = Array.from(outputTensor.data as Float32Array);
|
||||
|
||||
const drawing = predictions[0] ?? 0;
|
||||
const neutral = predictions[2] ?? 0;
|
||||
const porn = predictions[3] ?? 0;
|
||||
const sexy = predictions[4] ?? 0;
|
||||
|
||||
const predictionMap = {
|
||||
drawing,
|
||||
// NOTE: hentai: predictions[1], gives false positives
|
||||
hentai: 0,
|
||||
neutral,
|
||||
porn,
|
||||
sexy,
|
||||
};
|
||||
|
||||
const nsfwProbability = predictionMap.hentai + predictionMap.porn + predictionMap.sexy;
|
||||
|
||||
return {
|
||||
isNSFW: nsfwProbability > this.NSFW_THRESHOLD,
|
||||
probability: nsfwProbability,
|
||||
predictions: predictionMap,
|
||||
};
|
||||
}
|
||||
|
||||
private async preprocessImage(buffer: Buffer): Promise<Float32Array> {
|
||||
const imageBuffer = await sharp(buffer)
|
||||
.resize(MODEL_SIZE, MODEL_SIZE, {fit: 'fill'})
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
const float32Array = new Float32Array(MODEL_SIZE * MODEL_SIZE * 3);
|
||||
const mean = [104, 117, 123];
|
||||
|
||||
for (let i = 0; i < imageBuffer.length; i += 3) {
|
||||
float32Array[i] = (imageBuffer[i + 2] ?? 0) - (mean[0] ?? 0);
|
||||
float32Array[i + 1] = (imageBuffer[i + 1] ?? 0) - (mean[1] ?? 0);
|
||||
float32Array[i + 2] = (imageBuffer[i] ?? 0) - (mean[2] ?? 0);
|
||||
}
|
||||
|
||||
return float32Array;
|
||||
}
|
||||
}
|
||||
224
packages/media_proxy/src/lib/S3Utils.tsx
Normal file
224
packages/media_proxy/src/lib/S3Utils.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {PassThrough, Stream} from 'node:stream';
|
||||
import {GetObjectCommand, HeadObjectCommand, S3Client, S3ServiceException} from '@aws-sdk/client-s3';
|
||||
import type {S3Config} from '@fluxer/media_proxy/src/types/MediaProxyConfig';
|
||||
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
|
||||
const MAX_STREAM_BYTES = 500 * 1024 * 1024;
|
||||
|
||||
export function createS3Client(config: S3Config): S3Client {
|
||||
return new S3Client({
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
});
|
||||
}
|
||||
|
||||
export interface S3HeadResult {
|
||||
contentLength: number;
|
||||
contentType: string;
|
||||
lastModified?: Date | undefined;
|
||||
}
|
||||
|
||||
export function createS3Utils(client: S3Client, metrics?: MetricsInterface) {
|
||||
const headS3Object = async (bucket: string, key: string): Promise<S3HeadResult> => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const {ContentLength, ContentType, LastModified} = await client.send(command);
|
||||
|
||||
metrics?.histogram({
|
||||
name: 'media_proxy.upstream.latency',
|
||||
dimensions: {operation: 'head'},
|
||||
valueMs: Date.now() - start,
|
||||
});
|
||||
|
||||
return {
|
||||
contentLength: ContentLength ?? 0,
|
||||
contentType: ContentType ?? 'application/octet-stream',
|
||||
lastModified: LastModified,
|
||||
};
|
||||
} catch (error) {
|
||||
metrics?.histogram({
|
||||
name: 'media_proxy.upstream.latency',
|
||||
dimensions: {operation: 'head'},
|
||||
valueMs: Date.now() - start,
|
||||
});
|
||||
if (error instanceof S3ServiceException) {
|
||||
metrics?.counter({
|
||||
name: 'media_proxy.s3.error',
|
||||
dimensions: {operation: 'head', error_type: error.name},
|
||||
});
|
||||
if (error.name === 'NotFound') {
|
||||
throw new HTTPException(404);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const readS3Object = async (bucket: string, key: string, range?: {start: number; end: number}) => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Range: range ? `bytes=${range.start}-${range.end}` : undefined,
|
||||
});
|
||||
|
||||
const {Body, ContentLength, LastModified, ContentType} = await client.send(command);
|
||||
assert(Body != null && ContentType != null);
|
||||
|
||||
if (range) {
|
||||
assert(Body instanceof Stream);
|
||||
const stream = Body instanceof PassThrough ? Body : Body.pipe(new PassThrough());
|
||||
metrics?.histogram({
|
||||
name: 'media_proxy.upstream.latency',
|
||||
dimensions: {operation: 'read'},
|
||||
valueMs: Date.now() - start,
|
||||
});
|
||||
return {data: stream, size: ContentLength || 0, contentType: ContentType, lastModified: LastModified};
|
||||
}
|
||||
|
||||
const chunks: Array<Buffer> = [];
|
||||
let totalSize = 0;
|
||||
|
||||
assert(Body instanceof Stream);
|
||||
const stream = Body instanceof PassThrough ? Body : Body.pipe(new PassThrough());
|
||||
|
||||
for await (const chunk of stream) {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_STREAM_BYTES) throw new HTTPException(413);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
metrics?.histogram({
|
||||
name: 'media_proxy.upstream.latency',
|
||||
dimensions: {operation: 'read'},
|
||||
valueMs: Date.now() - start,
|
||||
});
|
||||
return {data: Buffer.concat(chunks), size: totalSize, contentType: ContentType, lastModified: LastModified};
|
||||
} catch (error) {
|
||||
metrics?.histogram({
|
||||
name: 'media_proxy.upstream.latency',
|
||||
dimensions: {operation: 'read'},
|
||||
valueMs: Date.now() - start,
|
||||
});
|
||||
if (error instanceof S3ServiceException) {
|
||||
metrics?.counter({
|
||||
name: 'media_proxy.s3.error',
|
||||
dimensions: {operation: 'read', error_type: error.name},
|
||||
});
|
||||
if (error.name === 'NoSuchKey') {
|
||||
throw new HTTPException(404);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const streamS3Object = async (bucket: string, key: string) => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const {Body, ContentLength, LastModified, ContentType} = await client.send(command);
|
||||
assert(Body != null && ContentType != null);
|
||||
|
||||
assert(Body instanceof Stream, 'Expected S3 response body to be a stream');
|
||||
const stream = Body instanceof PassThrough ? Body : Body.pipe(new PassThrough());
|
||||
|
||||
metrics?.histogram({
|
||||
name: 'media_proxy.upstream.latency',
|
||||
dimensions: {operation: 'stream'},
|
||||
valueMs: Date.now() - start,
|
||||
});
|
||||
return {stream, size: ContentLength || 0, contentType: ContentType, lastModified: LastModified};
|
||||
} catch (error) {
|
||||
metrics?.histogram({
|
||||
name: 'media_proxy.upstream.latency',
|
||||
dimensions: {operation: 'stream'},
|
||||
valueMs: Date.now() - start,
|
||||
});
|
||||
if (error instanceof S3ServiceException) {
|
||||
metrics?.counter({
|
||||
name: 'media_proxy.s3.error',
|
||||
dimensions: {operation: 'stream', error_type: error.name},
|
||||
});
|
||||
if (error.name === 'NoSuchKey') {
|
||||
throw new HTTPException(404);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {headS3Object, readS3Object, streamS3Object};
|
||||
}
|
||||
|
||||
export type S3Utils = ReturnType<typeof createS3Utils>;
|
||||
|
||||
export async function streamToBuffer(stream: ReadableStream<Uint8Array> | null): Promise<Buffer> {
|
||||
if (!stream) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
const chunks: Array<Uint8Array> = [];
|
||||
let totalSize = 0;
|
||||
const reader = stream.getReader();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
totalSize += value.length;
|
||||
if (totalSize > MAX_STREAM_BYTES) throw new HTTPException(413);
|
||||
chunks.push(value);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const result = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return Buffer.from(result);
|
||||
}
|
||||
39
packages/media_proxy/src/middleware/AuthMiddleware.tsx
Normal file
39
packages/media_proxy/src/middleware/AuthMiddleware.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {timingSafeEqual} from 'node:crypto';
|
||||
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
|
||||
export function createInternalNetworkRequired(secretKey: string) {
|
||||
return createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||
const authHeader = ctx.req.header('Authorization');
|
||||
const expectedAuth = `Bearer ${secretKey}`;
|
||||
if (!authHeader) {
|
||||
throw new HTTPException(401, {message: 'Unauthorized'});
|
||||
}
|
||||
const authBuffer = Buffer.from(authHeader, 'utf8');
|
||||
const expectedBuffer = Buffer.from(expectedAuth, 'utf8');
|
||||
if (authBuffer.length !== expectedBuffer.length || !timingSafeEqual(authBuffer, expectedBuffer)) {
|
||||
throw new HTTPException(401, {message: 'Unauthorized'});
|
||||
}
|
||||
await next();
|
||||
});
|
||||
}
|
||||
61
packages/media_proxy/src/middleware/CloudflareFirewall.tsx
Normal file
61
packages/media_proxy/src/middleware/CloudflareFirewall.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {CloudflareEdgeIPService} from '@fluxer/media_proxy/src/lib/CloudflareEdgeIPService';
|
||||
import type {HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
|
||||
interface CloudflareFirewallOptions {
|
||||
enabled: boolean;
|
||||
exemptPaths?: Array<string>;
|
||||
}
|
||||
|
||||
export function createCloudflareFirewall(
|
||||
ipService: CloudflareEdgeIPService,
|
||||
logger: LoggerInterface,
|
||||
{enabled, exemptPaths = ['/_health', '/_metadata']}: CloudflareFirewallOptions,
|
||||
) {
|
||||
return createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||
if (!enabled) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const path = ctx.req.path;
|
||||
if (exemptPaths.some((prefix) => path === prefix || path.startsWith(prefix))) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const xff = ctx.req.header('x-forwarded-for');
|
||||
if (!xff) {
|
||||
logger.warn({path}, 'Rejected request without X-Forwarded-For header');
|
||||
throw new HTTPException(403, {message: 'Forbidden'});
|
||||
}
|
||||
const connectingIP = xff.split(',')[0]?.trim();
|
||||
if (!connectingIP || !ipService.isFromCloudflareEdge(connectingIP)) {
|
||||
logger.warn({connectingIP, path}, 'Rejected request from non-Cloudflare edge IP');
|
||||
throw new HTTPException(403, {message: 'Forbidden'});
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
}
|
||||
154
packages/media_proxy/src/middleware/MetricsMiddleware.tsx
Normal file
154
packages/media_proxy/src/middleware/MetricsMiddleware.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ErrorContext, ErrorType, HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
|
||||
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
|
||||
function getRouteFromPath(path: string): string | null {
|
||||
if (path === '/_health' || path === '/internal/telemetry') return null;
|
||||
if (path.startsWith('/avatars/')) return 'avatars';
|
||||
if (path.startsWith('/icons/')) return 'icons';
|
||||
if (path.startsWith('/banners/')) return 'banners';
|
||||
if (path.startsWith('/emojis/')) return 'emojis';
|
||||
if (path.startsWith('/stickers/')) return 'stickers';
|
||||
if (path.startsWith('/attachments/')) return 'attachments';
|
||||
if (path.startsWith('/external/')) return 'external';
|
||||
if (path.startsWith('/guilds/')) return 'guild_assets';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function getErrorTypeFromStatus(status: number): ErrorType {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return 'bad_request';
|
||||
case 401:
|
||||
return 'unauthorized';
|
||||
case 403:
|
||||
return 'forbidden';
|
||||
case 404:
|
||||
return 'not_found';
|
||||
case 408:
|
||||
return 'timeout';
|
||||
case 413:
|
||||
return 'payload_too_large';
|
||||
default:
|
||||
if (status >= 500 && status < 600) {
|
||||
return 'upstream_5xx';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
export function createMetricsMiddleware(metrics: MetricsInterface) {
|
||||
return createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||
const start = Date.now();
|
||||
let errorType: ErrorType | undefined;
|
||||
let errorSource: string | undefined;
|
||||
|
||||
try {
|
||||
await next();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('timeout') || message.includes('timed out') || message.includes('etimedout')) {
|
||||
errorType = 'timeout';
|
||||
errorSource = 'network';
|
||||
} else if (
|
||||
message.includes('econnrefused') ||
|
||||
message.includes('econnreset') ||
|
||||
message.includes('enotfound')
|
||||
) {
|
||||
errorType = 'upstream_5xx';
|
||||
errorSource = 'network';
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
const duration = Date.now() - start;
|
||||
const route = getRouteFromPath(ctx.req.path);
|
||||
|
||||
if (route !== null) {
|
||||
const status = ctx.res.status;
|
||||
|
||||
const baseDimensions = {
|
||||
'http.request.method': ctx.req.method,
|
||||
'url.path': route,
|
||||
'http.response.status_code': String(status),
|
||||
};
|
||||
|
||||
metrics.histogram({
|
||||
name: 'http.server.request.duration',
|
||||
dimensions: baseDimensions,
|
||||
valueMs: duration,
|
||||
});
|
||||
|
||||
metrics.counter({
|
||||
name: 'http.server.request.count',
|
||||
dimensions: baseDimensions,
|
||||
value: 1,
|
||||
});
|
||||
|
||||
metrics.histogram({
|
||||
name: 'media_proxy.latency',
|
||||
dimensions: {route},
|
||||
valueMs: duration,
|
||||
});
|
||||
|
||||
metrics.counter({
|
||||
name: 'media_proxy.request',
|
||||
dimensions: {route, status: String(status)},
|
||||
});
|
||||
|
||||
if (status >= 400) {
|
||||
const errorContext = ctx.get('metricsErrorContext') as ErrorContext | undefined;
|
||||
const finalErrorType = errorContext?.errorType ?? errorType ?? getErrorTypeFromStatus(status);
|
||||
const finalErrorSource = errorContext?.errorSource ?? errorSource ?? 'handler';
|
||||
|
||||
metrics.counter({
|
||||
name: 'media_proxy.failure',
|
||||
dimensions: {
|
||||
route,
|
||||
status: String(status),
|
||||
error_type: finalErrorType,
|
||||
error_source: finalErrorSource,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
metrics.counter({
|
||||
name: 'media_proxy.success',
|
||||
dimensions: {route, status: String(status)},
|
||||
});
|
||||
}
|
||||
|
||||
const contentLength = ctx.res.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
const bytes = Number.parseInt(contentLength, 10);
|
||||
if (!Number.isNaN(bytes)) {
|
||||
metrics.counter({
|
||||
name: 'media_proxy.bytes',
|
||||
dimensions: {route},
|
||||
value: bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
55
packages/media_proxy/src/schemas/ValidationSchemas.tsx
Normal file
55
packages/media_proxy/src/schemas/ValidationSchemas.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUE,
|
||||
MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUES,
|
||||
} from '@fluxer/constants/src/MediaProxyImageSizes';
|
||||
import * as v from 'valibot';
|
||||
|
||||
export const ImageParamSchema = v.object({
|
||||
id: v.string(),
|
||||
filename: v.pipe(
|
||||
v.string(),
|
||||
v.minLength(1),
|
||||
v.maxLength(100),
|
||||
v.regex(/^[a-zA-Z0-9_]+\.[a-zA-Z0-9]+$/, 'Invalid filename'),
|
||||
),
|
||||
});
|
||||
|
||||
export const ImageQuerySchema = v.object({
|
||||
size: v.optional(v.picklist(MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUES), DEFAULT_MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUE),
|
||||
format: v.optional(v.picklist(['png', 'jpg', 'jpeg', 'webp', 'gif']), 'webp'),
|
||||
quality: v.optional(v.picklist(['high', 'low', 'lossless']), 'high'),
|
||||
animated: v.pipe(
|
||||
v.optional(v.picklist(['true', 'false']), 'false'),
|
||||
v.transform((v) => v === 'true'),
|
||||
),
|
||||
});
|
||||
|
||||
export const ExternalQuerySchema = v.object({
|
||||
width: v.optional(v.pipe(v.string(), v.transform(Number), v.number(), v.integer(), v.minValue(1), v.maxValue(4096))),
|
||||
height: v.optional(v.pipe(v.string(), v.transform(Number), v.number(), v.integer(), v.minValue(1), v.maxValue(4096))),
|
||||
format: v.optional(v.picklist(['png', 'jpg', 'jpeg', 'webp', 'gif'])),
|
||||
quality: v.optional(v.picklist(['high', 'low', 'lossless']), 'lossless'),
|
||||
animated: v.pipe(
|
||||
v.optional(v.picklist(['true', 'false']), 'false'),
|
||||
v.transform((v) => v === 'true'),
|
||||
),
|
||||
});
|
||||
154
packages/media_proxy/src/services/FrameService.tsx
Normal file
154
packages/media_proxy/src/services/FrameService.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import {
|
||||
computeFrameSampleTimestamps,
|
||||
FFmpegTimeoutError,
|
||||
type FrameExtractor,
|
||||
ffprobe,
|
||||
} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
|
||||
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
|
||||
import type {S3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
|
||||
import type {FrameRequest, FrameResponse, IFrameService} from '@fluxer/media_proxy/src/types/MediaProxyServices';
|
||||
import sharp from 'sharp';
|
||||
import {temporaryFile} from 'tempy';
|
||||
|
||||
export interface FrameServiceDeps {
|
||||
s3Utils: S3Utils;
|
||||
mimeTypeUtils: MimeTypeUtils;
|
||||
frameExtractor: FrameExtractor;
|
||||
logger: LoggerInterface;
|
||||
bucketUploads: string;
|
||||
}
|
||||
|
||||
export function createFrameService(deps: FrameServiceDeps): IFrameService {
|
||||
const {s3Utils, mimeTypeUtils, frameExtractor, logger, bucketUploads} = deps;
|
||||
const {readS3Object} = s3Utils;
|
||||
const {getMimeType, getMediaCategory} = mimeTypeUtils;
|
||||
const {extractFramesAtTimes} = frameExtractor;
|
||||
|
||||
async function extractFrames(request: FrameRequest): Promise<FrameResponse> {
|
||||
let data: Buffer;
|
||||
let filename: string;
|
||||
let mimeType: string | null | undefined;
|
||||
|
||||
try {
|
||||
if (request.type === 'upload') {
|
||||
const result = await readS3Object(bucketUploads, request.upload_filename);
|
||||
assert(result.data instanceof Buffer);
|
||||
data = result.data;
|
||||
filename = request.upload_filename;
|
||||
} else {
|
||||
const result = await readS3Object(request.bucket, request.key);
|
||||
assert(result.data instanceof Buffer);
|
||||
data = result.data;
|
||||
filename = request.key.split('/').pop() ?? request.key;
|
||||
}
|
||||
|
||||
mimeType = getMimeType(data, filename);
|
||||
if (!mimeType) {
|
||||
logger.warn({source: filename}, 'Unable to determine file type for frame extraction, returning empty frames');
|
||||
return {frames: []};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({error, request}, 'Failed to read file for frame extraction, returning empty frames');
|
||||
return {frames: []};
|
||||
}
|
||||
|
||||
if (!mimeType || !data) {
|
||||
return {frames: []};
|
||||
}
|
||||
|
||||
const tempFilePath = temporaryFile({extension: 'tmp'});
|
||||
const tempFiles: Array<string> = [tempFilePath];
|
||||
|
||||
try {
|
||||
await fs.writeFile(tempFilePath, data);
|
||||
|
||||
const probeResult = await ffprobe(tempFilePath);
|
||||
const rawDuration = probeResult.format?.duration;
|
||||
const durationSeconds =
|
||||
typeof rawDuration === 'string' && Number.isFinite(Number.parseFloat(rawDuration))
|
||||
? Number.parseFloat(rawDuration)
|
||||
: null;
|
||||
|
||||
const hasVideoStream = probeResult.streams?.some((stream) => stream.codec_type === 'video') ?? false;
|
||||
|
||||
let isAnimatedImage = false;
|
||||
if (getMediaCategory(mimeType) === 'image') {
|
||||
try {
|
||||
const metadata = await sharp(data, {animated: true}).metadata();
|
||||
isAnimatedImage = (metadata.pages ?? 1) > 1;
|
||||
} catch (error) {
|
||||
logger.debug({error, source: filename}, 'Unable to detect animation pages');
|
||||
}
|
||||
}
|
||||
|
||||
const isRealVideo = hasVideoStream && durationSeconds !== null && durationSeconds > 0;
|
||||
|
||||
const frames: Array<{timestamp: number; mimeType: string; buffer: Buffer}> = [];
|
||||
if (isRealVideo || isAnimatedImage) {
|
||||
const timestamps = computeFrameSampleTimestamps(durationSeconds);
|
||||
const framePaths = await extractFramesAtTimes(tempFilePath, timestamps);
|
||||
for (let i = 0; i < framePaths.length; i++) {
|
||||
const framePath = framePaths[i];
|
||||
if (!framePath) continue;
|
||||
tempFiles.push(framePath);
|
||||
const frameData = await fs.readFile(framePath);
|
||||
frames.push({
|
||||
timestamp: timestamps[i] ?? 0,
|
||||
mimeType: 'image/jpeg',
|
||||
buffer: frameData,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
frames.push({
|
||||
timestamp: 0,
|
||||
mimeType,
|
||||
buffer: data,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
frames: frames.map((frame) => ({
|
||||
timestamp: frame.timestamp,
|
||||
mime_type: frame.mimeType,
|
||||
base64: frame.buffer.toString('base64'),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({error, source: filename}, 'Failed to extract media frames, returning empty frames');
|
||||
if (error instanceof FFmpegTimeoutError) {
|
||||
throw new Error(`Frame extraction timed out: ${error.operation}`);
|
||||
}
|
||||
return {frames: []};
|
||||
} finally {
|
||||
await Promise.all(
|
||||
tempFiles.map((file) => fs.unlink(file).catch(() => logger.error(`Failed to delete temp file: ${file}`))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
extractFrames,
|
||||
};
|
||||
}
|
||||
220
packages/media_proxy/src/services/MetadataService.tsx
Normal file
220
packages/media_proxy/src/services/MetadataService.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import type {HttpClient} from '@fluxer/http_client/src/HttpClientTypes';
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {FFmpegUtilsType} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
|
||||
import type {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
|
||||
import type {MediaValidator} from '@fluxer/media_proxy/src/lib/MediaValidation';
|
||||
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
|
||||
import type {NSFWDetectionService} from '@fluxer/media_proxy/src/lib/NSFWDetectionService';
|
||||
import type {S3Utils} from '@fluxer/media_proxy/src/lib/S3Utils';
|
||||
import {streamToBuffer} from '@fluxer/media_proxy/src/lib/S3Utils';
|
||||
import type {
|
||||
IMetadataService,
|
||||
MetadataRequest,
|
||||
MetadataResponse,
|
||||
ThumbnailRequest,
|
||||
} from '@fluxer/media_proxy/src/types/MediaProxyServices';
|
||||
import sharp from 'sharp';
|
||||
import {temporaryFile} from 'tempy';
|
||||
|
||||
export interface MetadataServiceDeps {
|
||||
coalescer: InMemoryCoalescer;
|
||||
nsfwDetectionService: NSFWDetectionService;
|
||||
s3Utils: S3Utils;
|
||||
httpClient: HttpClient;
|
||||
mimeTypeUtils: MimeTypeUtils;
|
||||
mediaValidator: MediaValidator;
|
||||
ffmpegUtils: FFmpegUtilsType;
|
||||
logger: LoggerInterface;
|
||||
bucketUploads: string;
|
||||
}
|
||||
|
||||
export function createMetadataService(deps: MetadataServiceDeps): IMetadataService {
|
||||
const {
|
||||
coalescer,
|
||||
nsfwDetectionService,
|
||||
s3Utils,
|
||||
httpClient,
|
||||
mimeTypeUtils,
|
||||
mediaValidator,
|
||||
ffmpegUtils,
|
||||
logger,
|
||||
bucketUploads,
|
||||
} = deps;
|
||||
const {readS3Object} = s3Utils;
|
||||
const {getMimeType, generateFilename} = mimeTypeUtils;
|
||||
const {validateMedia, processMetadata} = mediaValidator;
|
||||
const {createThumbnail} = ffmpegUtils;
|
||||
|
||||
async function getMetadata(request: MetadataRequest): Promise<MetadataResponse> {
|
||||
const cacheKey = (() => {
|
||||
switch (request.type) {
|
||||
case 'base64':
|
||||
return `base64_${request.base64}`;
|
||||
case 'upload':
|
||||
return `upload_${request.upload_filename}`;
|
||||
case 'external':
|
||||
return `external_${request.url}_${request.with_base64}`;
|
||||
case 's3':
|
||||
return `s3_${request.bucket}_${request.key}_${request.with_base64}`;
|
||||
}
|
||||
})();
|
||||
|
||||
return coalescer.coalesce(cacheKey, async () => {
|
||||
const tempFiles: Array<string> = [];
|
||||
try {
|
||||
const {buffer, filename} = await (async () => {
|
||||
switch (request.type) {
|
||||
case 'base64':
|
||||
return {buffer: Buffer.from(request.base64, 'base64'), filename: undefined};
|
||||
|
||||
case 'upload': {
|
||||
const {data} = await readS3Object(bucketUploads, request.upload_filename);
|
||||
assert(data instanceof Buffer);
|
||||
return {buffer: data, filename: request.filename ?? request.upload_filename};
|
||||
}
|
||||
|
||||
case 's3': {
|
||||
const {data} = await readS3Object(request.bucket, request.key);
|
||||
assert(data instanceof Buffer);
|
||||
const fname = request.key.substring(request.key.lastIndexOf('/') + 1);
|
||||
return {buffer: data, filename: fname || undefined};
|
||||
}
|
||||
|
||||
case 'external': {
|
||||
const response = await httpClient.sendRequest({url: request.url});
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Failed to fetch media');
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const fname = url.pathname.substring(url.pathname.lastIndexOf('/') + 1);
|
||||
return {buffer: await streamToBuffer(response.stream), filename: fname || undefined};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error('Invalid request type');
|
||||
}
|
||||
})();
|
||||
|
||||
let effectiveFilename = filename;
|
||||
if (!effectiveFilename && request.type === 'base64') {
|
||||
const detectedMime = getMimeType(buffer);
|
||||
if (detectedMime) {
|
||||
effectiveFilename = generateFilename(detectedMime);
|
||||
} else {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
if (metadata.format) {
|
||||
effectiveFilename = `image.${metadata.format.toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!effectiveFilename) {
|
||||
throw new Error('Cannot determine file type');
|
||||
}
|
||||
|
||||
const mimeType = await validateMedia(buffer, effectiveFilename);
|
||||
const metadata = await processMetadata(mimeType, buffer);
|
||||
const contentHash = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
let nsfw = false;
|
||||
let nsfwProbability = 0;
|
||||
let nsfwPredictions: Record<string, number> = {};
|
||||
if (!request.isNSFWAllowed) {
|
||||
const isImageOrVideo = mimeType.startsWith('image/') || mimeType.startsWith('video/');
|
||||
if (isImageOrVideo) {
|
||||
try {
|
||||
let checkBuffer = buffer;
|
||||
if (mimeType.startsWith('video/')) {
|
||||
const videoExtension = mimeType.split('/')[1] ?? 'tmp';
|
||||
const tempPath = temporaryFile({extension: videoExtension});
|
||||
tempFiles.push(tempPath);
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
|
||||
const thumbnailPath = await createThumbnail(tempPath);
|
||||
tempFiles.push(thumbnailPath);
|
||||
|
||||
checkBuffer = await fs.readFile(thumbnailPath);
|
||||
}
|
||||
const nsfwResult = await nsfwDetectionService.checkNSFWBuffer(checkBuffer);
|
||||
nsfw = nsfwResult.isNSFW;
|
||||
nsfwProbability = nsfwResult.probability;
|
||||
nsfwPredictions = nsfwResult.predictions ?? {};
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Failed to perform NSFW detection');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
content_type: mimeType,
|
||||
content_hash: contentHash,
|
||||
nsfw,
|
||||
nsfw_probability: nsfwProbability,
|
||||
nsfw_predictions: nsfwPredictions,
|
||||
base64:
|
||||
(request.type === 'external' || request.type === 's3') && request.with_base64
|
||||
? buffer.toString('base64')
|
||||
: undefined,
|
||||
};
|
||||
} finally {
|
||||
await Promise.all(
|
||||
tempFiles.map((file) => fs.unlink(file).catch(() => logger.error(`Failed to delete temp file: ${file}`))),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getThumbnail(request: ThumbnailRequest): Promise<Buffer> {
|
||||
const {data} = await readS3Object(bucketUploads, request.upload_filename);
|
||||
assert(data instanceof Buffer);
|
||||
|
||||
const mimeType = getMimeType(data, request.upload_filename);
|
||||
if (!mimeType || !mimeType.startsWith('video/')) {
|
||||
throw new Error('File is not a video');
|
||||
}
|
||||
|
||||
const videoExtension = mimeType.split('/')[1] ?? 'tmp';
|
||||
const tempPath = temporaryFile({extension: videoExtension});
|
||||
const tempFiles: Array<string> = [tempPath];
|
||||
|
||||
try {
|
||||
await fs.writeFile(tempPath, data);
|
||||
const thumbnailPath = await createThumbnail(tempPath);
|
||||
tempFiles.push(thumbnailPath);
|
||||
return await fs.readFile(thumbnailPath);
|
||||
} finally {
|
||||
await Promise.all(
|
||||
tempFiles.map((file) => fs.unlink(file).catch(() => logger.error(`Failed to delete temp file: ${file}`))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getMetadata,
|
||||
getThumbnail,
|
||||
};
|
||||
}
|
||||
40
packages/media_proxy/src/types/HonoEnv.tsx
Normal file
40
packages/media_proxy/src/types/HonoEnv.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export type ErrorType =
|
||||
| 'timeout'
|
||||
| 'upstream_5xx'
|
||||
| 'not_found'
|
||||
| 'bad_request'
|
||||
| 'forbidden'
|
||||
| 'unauthorized'
|
||||
| 'payload_too_large'
|
||||
| 'other';
|
||||
|
||||
export interface ErrorContext {
|
||||
errorType?: ErrorType;
|
||||
errorSource?: string;
|
||||
}
|
||||
|
||||
export interface HonoEnv {
|
||||
Variables: {
|
||||
tempFiles: Array<string>;
|
||||
metricsErrorContext?: ErrorContext;
|
||||
};
|
||||
}
|
||||
37
packages/media_proxy/src/types/MediaProxyConfig.tsx
Normal file
37
packages/media_proxy/src/types/MediaProxyConfig.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface S3Config {
|
||||
endpoint: string;
|
||||
region: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucketCdn: string;
|
||||
bucketUploads: string;
|
||||
bucketStatic?: string | undefined;
|
||||
}
|
||||
|
||||
export interface MediaProxyConfig {
|
||||
nodeEnv: 'development' | 'production';
|
||||
secretKey: string;
|
||||
requireCloudflareEdge: boolean;
|
||||
staticMode: boolean;
|
||||
s3: S3Config;
|
||||
nsfwModelPath?: string | undefined;
|
||||
}
|
||||
70
packages/media_proxy/src/types/MediaProxyServices.tsx
Normal file
70
packages/media_proxy/src/types/MediaProxyServices.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export type MetadataRequest =
|
||||
| {type: 'external'; url: string; with_base64?: boolean; isNSFWAllowed: boolean}
|
||||
| {type: 'upload'; upload_filename: string; filename?: string; isNSFWAllowed: boolean}
|
||||
| {type: 'base64'; base64: string; isNSFWAllowed: boolean}
|
||||
| {type: 's3'; bucket: string; key: string; with_base64?: boolean; isNSFWAllowed: boolean};
|
||||
|
||||
export interface MetadataResponse {
|
||||
format: string;
|
||||
content_type: string;
|
||||
content_hash: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
placeholder?: string;
|
||||
base64?: string;
|
||||
animated?: boolean;
|
||||
nsfw: boolean;
|
||||
nsfw_probability?: number;
|
||||
nsfw_predictions?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ThumbnailRequest {
|
||||
upload_filename: string;
|
||||
}
|
||||
|
||||
export interface IMetadataService {
|
||||
getMetadata(request: MetadataRequest): Promise<MetadataResponse>;
|
||||
getThumbnail(request: ThumbnailRequest): Promise<Buffer>;
|
||||
}
|
||||
|
||||
export type FrameRequest = {type: 'upload'; upload_filename: string} | {type: 's3'; bucket: string; key: string};
|
||||
|
||||
export interface FrameData {
|
||||
timestamp: number;
|
||||
mime_type: string;
|
||||
base64: string;
|
||||
}
|
||||
|
||||
export interface FrameResponse {
|
||||
frames: Array<FrameData>;
|
||||
}
|
||||
|
||||
export interface IFrameService {
|
||||
extractFrames(request: FrameRequest): Promise<FrameResponse>;
|
||||
}
|
||||
|
||||
export interface MediaProxyServices {
|
||||
metadataService: IMetadataService;
|
||||
frameService: IFrameService;
|
||||
}
|
||||
43
packages/media_proxy/src/types/Metrics.tsx
Normal file
43
packages/media_proxy/src/types/Metrics.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface CounterParams {
|
||||
name: string;
|
||||
dimensions?: Record<string, string>;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export interface HistogramParams {
|
||||
name: string;
|
||||
dimensions?: Record<string, string>;
|
||||
valueMs: number;
|
||||
}
|
||||
|
||||
export interface GaugeParams {
|
||||
name: string;
|
||||
dimensions?: Record<string, string>;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface MetricsInterface {
|
||||
counter(params: CounterParams): void;
|
||||
histogram(params: HistogramParams): void;
|
||||
gauge(params: GaugeParams): void;
|
||||
isEnabled(): boolean;
|
||||
}
|
||||
30
packages/media_proxy/src/types/Tracing.tsx
Normal file
30
packages/media_proxy/src/types/Tracing.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Attributes} from '@opentelemetry/api';
|
||||
|
||||
export interface SpanOptions {
|
||||
name: string;
|
||||
attributes?: Attributes | undefined;
|
||||
}
|
||||
|
||||
export interface TracingInterface {
|
||||
withSpan<T>(options: SpanOptions, fn: () => Promise<T>): Promise<T>;
|
||||
addSpanEvent(name: string, attributes?: Attributes): void;
|
||||
}
|
||||
41
packages/media_proxy/src/utils/FetchUtils.test.tsx
Normal file
41
packages/media_proxy/src/utils/FetchUtils.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createHttpClient, type HttpError} from '@fluxer/media_proxy/src/utils/FetchUtils';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
describe('media proxy request URL policy', () => {
|
||||
test('blocks localhost metadata target', async () => {
|
||||
const client = createHttpClient('fluxer-media-proxy-test');
|
||||
const expectedError: Partial<HttpError> = {
|
||||
isExpected: true,
|
||||
};
|
||||
|
||||
await expect(client.sendRequest({url: 'http://localhost/_metadata'})).rejects.toMatchObject(expectedError);
|
||||
});
|
||||
|
||||
test('blocks private IP metadata target', async () => {
|
||||
const client = createHttpClient('fluxer-media-proxy-test');
|
||||
const expectedError: Partial<HttpError> = {
|
||||
isExpected: true,
|
||||
};
|
||||
|
||||
await expect(client.sendRequest({url: 'http://127.0.0.1:8080/path'})).rejects.toMatchObject(expectedError);
|
||||
});
|
||||
});
|
||||
116
packages/media_proxy/src/utils/FetchUtils.tsx
Normal file
116
packages/media_proxy/src/utils/FetchUtils.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createHttpClient as createHttpClientCore} from '@fluxer/http_client/src/HttpClient';
|
||||
import type {HttpClient, RequestOptions, StreamResponse} from '@fluxer/http_client/src/HttpClientTypes';
|
||||
import {HttpError as CoreHttpError} from '@fluxer/http_client/src/HttpError';
|
||||
import {createPublicInternetRequestUrlPolicy} from '@fluxer/http_client/src/PublicInternetRequestUrlPolicy';
|
||||
import type {ErrorType} from '@fluxer/media_proxy/src/types/HonoEnv';
|
||||
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
|
||||
import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status?: number,
|
||||
public readonly response?: Response,
|
||||
public readonly isExpected = false,
|
||||
public readonly errorType?: ErrorType,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
|
||||
function convertHttpError(err: unknown): never {
|
||||
if (err instanceof CoreHttpError && err instanceof Error) {
|
||||
throw new HttpError(
|
||||
err.message,
|
||||
err.status,
|
||||
err.response,
|
||||
err.isExpected,
|
||||
(err.errorType as ErrorType) ?? undefined,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
export function createHttpClient(
|
||||
userAgent: string,
|
||||
options?: {metrics?: MetricsInterface | undefined; tracing?: TracingInterface | undefined},
|
||||
): HttpClient {
|
||||
const {metrics, tracing} = options ?? {};
|
||||
|
||||
const coreClient: HttpClient = createHttpClientCore({
|
||||
userAgent,
|
||||
requestUrlPolicy: createPublicInternetRequestUrlPolicy(),
|
||||
telemetry: {
|
||||
metrics: metrics
|
||||
? {
|
||||
counter: (params) => {
|
||||
const newName = params.name.replace('http_client', 'media_proxy.origin');
|
||||
metrics.counter({...params, name: newName});
|
||||
},
|
||||
histogram: (params) => {
|
||||
const newName = params.name.replace('http_client', 'media_proxy.origin');
|
||||
metrics.histogram({...params, name: newName});
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
tracing: tracing
|
||||
? {
|
||||
withSpan: (spanOpts, fn) =>
|
||||
tracing.withSpan(
|
||||
{
|
||||
name: spanOpts.name.replace('http_client.fetch', 'origin.fetch'),
|
||||
attributes: spanOpts.attributes as Record<string, string | number | boolean | undefined>,
|
||||
},
|
||||
fn,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
async function sendRequest(opts: RequestOptions): Promise<StreamResponse> {
|
||||
try {
|
||||
const result = await coreClient.sendRequest(opts);
|
||||
const contentLength = result.headers.get('content-length');
|
||||
if (contentLength && metrics) {
|
||||
const bytes = Number.parseInt(contentLength, 10);
|
||||
if (!Number.isNaN(bytes)) {
|
||||
metrics.counter({
|
||||
name: 'media_proxy.origin.bytes',
|
||||
dimensions: {method: opts.method ?? 'GET'},
|
||||
value: bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
convertHttpError(error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
request: sendRequest,
|
||||
sendRequest,
|
||||
streamToString: coreClient.streamToString,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user