refactor progress
This commit is contained in:
@@ -1,171 +0,0 @@
|
||||
/*
|
||||
* 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 '~/instrument';
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import {serve} from '@hono/node-server';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {Hono} from 'hono';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import {logger} from 'hono/logger';
|
||||
import * as v from 'valibot';
|
||||
import {Config} from '~/Config';
|
||||
import {createAttachmentsHandler} from '~/controllers/AttachmentsController';
|
||||
import {createExternalMediaHandler} from '~/controllers/ExternalMediaController';
|
||||
import {
|
||||
createGuildMemberImageRouteHandler,
|
||||
createImageRouteHandler,
|
||||
createSimpleImageRouteHandler,
|
||||
} from '~/controllers/ImageController';
|
||||
import {handleMetadataRequest} from '~/controllers/MetadataController';
|
||||
import {handleStaticProxyRequest} from '~/controllers/StaticProxyController';
|
||||
import {createStickerRouteHandler} from '~/controllers/StickerController';
|
||||
import {handleThemeRequest} from '~/controllers/ThemeController';
|
||||
import {handleThumbnailRequest} from '~/controllers/ThumbnailController';
|
||||
import {Logger} from '~/Logger';
|
||||
import {CloudflareIPService} from '~/lib/CloudflareIPService';
|
||||
import {InMemoryCoalescer} from '~/lib/InMemoryCoalescer';
|
||||
import type {HonoEnv} from '~/lib/MediaTypes';
|
||||
import {NSFWDetectionService} from '~/lib/NSFWDetectionService';
|
||||
import {InternalNetworkRequired} from '~/middleware/AuthMiddleware';
|
||||
import {createCloudflareFirewall} from '~/middleware/CloudflareFirewall';
|
||||
import {metricsMiddleware} from '~/middleware/MetricsMiddleware';
|
||||
|
||||
const app = new Hono<HonoEnv>({strict: true});
|
||||
app.use(logger(Logger.info.bind(Logger)));
|
||||
app.use('*', metricsMiddleware);
|
||||
|
||||
const coalescer = new InMemoryCoalescer();
|
||||
Logger.info('Initialized in-memory request coalescer');
|
||||
|
||||
const cloudflareIPService = new CloudflareIPService();
|
||||
|
||||
if (Config.REQUIRE_CLOUDFLARE) {
|
||||
await cloudflareIPService.initialize();
|
||||
Logger.info('Initialized Cloudflare IP allowlist');
|
||||
} else {
|
||||
Logger.info('Cloudflare IP allowlist disabled');
|
||||
}
|
||||
|
||||
const cloudflareFirewall = createCloudflareFirewall(cloudflareIPService, {
|
||||
enabled: Config.REQUIRE_CLOUDFLARE,
|
||||
});
|
||||
|
||||
app.use('*', cloudflareFirewall);
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
Logger.info('Received SIGTERM, shutting down gracefully');
|
||||
try {
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Error during shutdown');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
app.use('*', async (ctx, next) => {
|
||||
ctx.set('tempFiles', []);
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
const tempFiles = ctx.get('tempFiles');
|
||||
await Promise.all(
|
||||
tempFiles.map((file) => fs.unlink(file).catch(() => Logger.error(`Failed to delete temp file: ${file}`))),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/_health', (ctx) => ctx.text('OK'));
|
||||
|
||||
if (Config.STATIC_MODE) {
|
||||
Logger.info('Media proxy running in STATIC MODE - proxying all requests to the static bucket');
|
||||
|
||||
app.all('*', handleStaticProxyRequest);
|
||||
} else {
|
||||
const nsfwDetectionService = new NSFWDetectionService();
|
||||
await nsfwDetectionService.initialize();
|
||||
Logger.info('Initialized NSFW detection service');
|
||||
|
||||
const handleImageRoute = createImageRouteHandler(coalescer);
|
||||
const handleSimpleImageRoute = createSimpleImageRouteHandler(coalescer);
|
||||
const handleGuildMemberImageRoute = createGuildMemberImageRouteHandler(coalescer);
|
||||
const handleStickerRoute = createStickerRouteHandler(coalescer);
|
||||
const processExternalMedia = createExternalMediaHandler(coalescer);
|
||||
const handleAttachmentsRoute = createAttachmentsHandler(coalescer);
|
||||
|
||||
app.post('/_metadata', InternalNetworkRequired, handleMetadataRequest(coalescer, nsfwDetectionService));
|
||||
app.post('/_thumbnail', InternalNetworkRequired, handleThumbnailRequest);
|
||||
|
||||
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 path = ctx.req.path.replace('/external/', '');
|
||||
return processExternalMedia(ctx, path);
|
||||
});
|
||||
}
|
||||
|
||||
app.use(
|
||||
logger((message: string, ...rest: Array<string>) => {
|
||||
Logger.info(rest.length > 0 ? `${message} ${rest.join(' ')}` : message);
|
||||
}),
|
||||
);
|
||||
|
||||
app.onError((err, ctx) => {
|
||||
const isExpectedError = err instanceof Error && 'isExpected' in err && err.isExpected;
|
||||
|
||||
if (!(v.isValiError(err) || err instanceof SyntaxError || err instanceof HTTPException || isExpectedError)) {
|
||||
Sentry.captureException(err);
|
||||
}
|
||||
|
||||
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});
|
||||
});
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
hostname: '0.0.0.0',
|
||||
port: Config.PORT,
|
||||
});
|
||||
|
||||
Logger.info({port: Config.PORT}, `Fluxer Media Proxy listening on http://0.0.0.0:${Config.PORT}`);
|
||||
@@ -17,4 +17,6 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const FLUXER_USER_AGENT = 'Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)';
|
||||
import '@app/Instrument';
|
||||
|
||||
import '@app/AppMain';
|
||||
96
fluxer_media_proxy/src/AppMain.tsx
Normal file
96
fluxer_media_proxy/src/AppMain.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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 {Config} from '@app/Config';
|
||||
import {shutdownInstrumentation} from '@app/Instrument';
|
||||
import {Logger} from '@app/Logger';
|
||||
import {createMetrics} from '@app/Metrics';
|
||||
import {createTracing} from '@app/Tracing';
|
||||
import {createServiceTelemetry} from '@fluxer/hono/src/middleware/TelemetryAdapters';
|
||||
import {createServer, setupGracefulShutdown} from '@fluxer/hono/src/Server';
|
||||
import {createMediaProxyApp} from '@fluxer/media_proxy/src/App';
|
||||
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
|
||||
import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
|
||||
import {createRateLimitService, type RateLimitService} from '@fluxer/rate_limit/src/RateLimitService';
|
||||
import {isTelemetryActive} from '@fluxer/telemetry/src/Telemetry';
|
||||
|
||||
const metrics: MetricsInterface = createMetrics();
|
||||
|
||||
const tracing: TracingInterface = createTracing();
|
||||
|
||||
let rateLimitService: RateLimitService | null = null;
|
||||
rateLimitService = createRateLimitService(null);
|
||||
|
||||
const requestTelemetry = createServiceTelemetry({
|
||||
serviceName: 'fluxer-media-proxy',
|
||||
skipPaths: ['/_health', '/internal/telemetry'],
|
||||
});
|
||||
|
||||
const {app, shutdown} = await createMediaProxyApp({
|
||||
config: {
|
||||
nodeEnv: Config.env,
|
||||
secretKey: Config.mediaProxy.secretKey,
|
||||
requireCloudflareEdge: Config.mediaProxy.requireCloudflareEdge,
|
||||
staticMode: Config.mediaProxy.staticMode,
|
||||
s3: {
|
||||
endpoint: Config.aws.s3Endpoint,
|
||||
region: Config.aws.s3Region,
|
||||
accessKeyId: Config.aws.accessKeyId,
|
||||
secretAccessKey: Config.aws.secretAccessKey,
|
||||
bucketCdn: Config.aws.s3BucketCdn,
|
||||
bucketUploads: Config.aws.s3BucketUploads,
|
||||
bucketStatic: Config.aws.s3BucketStatic,
|
||||
},
|
||||
},
|
||||
logger: Logger,
|
||||
metrics,
|
||||
tracing,
|
||||
requestMetricsCollector: requestTelemetry.metricsCollector,
|
||||
requestTracing: requestTelemetry.tracing,
|
||||
rateLimitService,
|
||||
rateLimitConfig:
|
||||
Config.rateLimit?.limit != null && Config.rateLimit.window_ms != null
|
||||
? {
|
||||
enabled: true,
|
||||
maxAttempts: Config.rateLimit.limit,
|
||||
windowMs: Config.rateLimit.window_ms,
|
||||
skipPaths: ['/_health'],
|
||||
}
|
||||
: null,
|
||||
onTelemetryRequest: async () => ({
|
||||
telemetry_enabled: isTelemetryActive(),
|
||||
service: 'fluxer_media_proxy',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
const server = createServer(app, {port: Config.server.port});
|
||||
|
||||
Logger.info({port: Config.server.port}, `Starting Fluxer Media Proxy on port ${Config.server.port}`);
|
||||
|
||||
setupGracefulShutdown(
|
||||
async () => {
|
||||
await shutdown();
|
||||
await shutdownInstrumentation();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
},
|
||||
{logger: Logger},
|
||||
);
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
* 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 process from 'node:process';
|
||||
import * as v from 'valibot';
|
||||
|
||||
function env(key: string): string {
|
||||
return process.env[key] || '';
|
||||
}
|
||||
|
||||
function envInt(key: string, defaultValue: number): number {
|
||||
const value = process.env[key];
|
||||
if (!value) return defaultValue;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
|
||||
function envBool(key: string, defaultValue: boolean): boolean {
|
||||
const value = process.env[key];
|
||||
if (!value) return defaultValue;
|
||||
|
||||
switch (value.toLowerCase()) {
|
||||
case '1':
|
||||
case 'true':
|
||||
case 'yes':
|
||||
case 'on':
|
||||
return true;
|
||||
case '0':
|
||||
case 'false':
|
||||
case 'no':
|
||||
case 'off':
|
||||
return false;
|
||||
default:
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
const MediaProxyConfigSchema = v.looseObject({
|
||||
NODE_ENV: v.optional(v.picklist(['development', 'production']), 'production'),
|
||||
PORT: v.number(),
|
||||
AWS_ACCESS_KEY_ID: v.pipe(v.string(), v.nonEmpty()),
|
||||
AWS_SECRET_ACCESS_KEY: v.pipe(v.string(), v.nonEmpty()),
|
||||
AWS_S3_ENDPOINT: v.pipe(v.string(), v.url()),
|
||||
AWS_S3_BUCKET_CDN: v.pipe(v.string(), v.nonEmpty()),
|
||||
AWS_S3_BUCKET_UPLOADS: v.pipe(v.string(), v.nonEmpty()),
|
||||
SECRET_KEY: v.pipe(v.string(), v.nonEmpty()),
|
||||
REQUIRE_CLOUDFLARE: v.optional(v.boolean(), false),
|
||||
STATIC_MODE: v.optional(v.boolean(), false),
|
||||
AWS_S3_BUCKET_STATIC: v.optional(v.string()),
|
||||
});
|
||||
|
||||
export const Config = v.parse(MediaProxyConfigSchema, {
|
||||
NODE_ENV: (env('NODE_ENV') || 'production') as 'development' | 'production',
|
||||
PORT: envInt('FLUXER_MEDIA_PROXY_PORT', 8080),
|
||||
AWS_ACCESS_KEY_ID: env('AWS_ACCESS_KEY_ID'),
|
||||
AWS_SECRET_ACCESS_KEY: env('AWS_SECRET_ACCESS_KEY'),
|
||||
AWS_S3_ENDPOINT: env('AWS_S3_ENDPOINT'),
|
||||
AWS_S3_BUCKET_CDN: env('AWS_S3_BUCKET_CDN'),
|
||||
AWS_S3_BUCKET_UPLOADS: env('AWS_S3_BUCKET_UPLOADS'),
|
||||
SECRET_KEY: env('MEDIA_PROXY_SECRET_KEY'),
|
||||
REQUIRE_CLOUDFLARE: envBool('FLUXER_MEDIA_PROXY_REQUIRE_CLOUDFLARE', false),
|
||||
STATIC_MODE: envBool('FLUXER_MEDIA_PROXY_STATIC_MODE', false),
|
||||
AWS_S3_BUCKET_STATIC: env('AWS_S3_BUCKET_STATIC') || undefined,
|
||||
});
|
||||
53
fluxer_media_proxy/src/Config.tsx
Normal file
53
fluxer_media_proxy/src/Config.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 {loadConfig} from '@fluxer/config/src/ConfigLoader';
|
||||
import {extractBaseServiceConfig} from '@fluxer/config/src/ServiceConfigSlices';
|
||||
|
||||
const master = await loadConfig();
|
||||
const s3Config = master.s3;
|
||||
if (!s3Config?.buckets) {
|
||||
throw new Error('Media proxy requires S3 bucket configuration');
|
||||
}
|
||||
const mediaProxyBuckets = s3Config.buckets;
|
||||
|
||||
export const Config = {
|
||||
...extractBaseServiceConfig(master),
|
||||
env: master.env === 'test' ? 'development' : master.env,
|
||||
server: {
|
||||
port: master.services.media_proxy.port,
|
||||
},
|
||||
aws: {
|
||||
accessKeyId: s3Config.access_key_id,
|
||||
secretAccessKey: s3Config.secret_access_key,
|
||||
s3Endpoint: s3Config.endpoint,
|
||||
s3Region: s3Config.region,
|
||||
s3BucketCdn: mediaProxyBuckets.cdn,
|
||||
s3BucketUploads: mediaProxyBuckets.uploads,
|
||||
s3BucketStatic: mediaProxyBuckets.static,
|
||||
},
|
||||
mediaProxy: {
|
||||
secretKey: master.services.media_proxy.secret_key,
|
||||
requireCloudflareEdge: master.services.media_proxy.require_cloudflare_edge,
|
||||
staticMode: master.services.media_proxy.static_mode,
|
||||
},
|
||||
rateLimit: master.services.media_proxy.rate_limit ?? null,
|
||||
};
|
||||
|
||||
export type Config = typeof Config;
|
||||
@@ -17,29 +17,14 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as metrics from '~/lib/MetricsClient';
|
||||
import {Config} from '@app/Config';
|
||||
import {createServiceInstrumentation} from '@fluxer/initialization/src/CreateServiceInstrumentation';
|
||||
|
||||
export class InMemoryCoalescer {
|
||||
private pending = new Map<string, Promise<unknown>>();
|
||||
|
||||
async coalesce<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const existing = this.pending.get(key) as Promise<T> | undefined;
|
||||
if (existing) {
|
||||
metrics.counter({name: 'media_proxy.cache.hit'});
|
||||
return existing;
|
||||
}
|
||||
|
||||
metrics.counter({name: 'media_proxy.cache.miss'});
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.pending.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
this.pending.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
export const shutdownInstrumentation = createServiceInstrumentation({
|
||||
serviceName: 'fluxer-media-proxy',
|
||||
config: Config,
|
||||
instrumentations: {
|
||||
aws: true,
|
||||
fetch: true,
|
||||
},
|
||||
});
|
||||
@@ -17,15 +17,7 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {createLogger, type Logger as FluxerLogger} from '@fluxer/logger/src/Logger';
|
||||
|
||||
const SENTRY_DSN = process.env.SENTRY_DSN;
|
||||
|
||||
if (SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV || 'production',
|
||||
sendDefaultPii: true,
|
||||
});
|
||||
}
|
||||
export const Logger = createLogger('fluxer-media-proxy');
|
||||
export type Logger = FluxerLogger;
|
||||
47
fluxer_media_proxy/src/Metrics.tsx
Normal file
47
fluxer_media_proxy/src/Metrics.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 {
|
||||
CounterParams,
|
||||
GaugeParams,
|
||||
HistogramParams,
|
||||
MetricsInterface,
|
||||
} from '@fluxer/media_proxy/src/types/Metrics';
|
||||
import {recordCounter, recordGauge, recordHistogram} from '@fluxer/telemetry/src/Metrics';
|
||||
import {isTelemetryActive} from '@fluxer/telemetry/src/Telemetry';
|
||||
|
||||
export function createMetrics(): MetricsInterface {
|
||||
return {
|
||||
counter({name, dimensions = {}, value = 1}: CounterParams) {
|
||||
if (!isTelemetryActive()) return;
|
||||
recordCounter({name, dimensions, value});
|
||||
},
|
||||
histogram({name, dimensions = {}, valueMs}: HistogramParams) {
|
||||
if (!isTelemetryActive()) return;
|
||||
recordHistogram({name, dimensions, valueMs});
|
||||
},
|
||||
gauge({name, dimensions = {}, value}: GaugeParams) {
|
||||
if (!isTelemetryActive()) return;
|
||||
recordGauge({name, dimensions, value});
|
||||
},
|
||||
isEnabled() {
|
||||
return isTelemetryActive();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,37 +17,19 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import pino from 'pino';
|
||||
import {Config} from '~/Config';
|
||||
import type {SpanOptions, TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
|
||||
import {addSpanEvent as telemetryAddSpanEvent, withSpan as telemetryWithSpan} from '@fluxer/telemetry/src/Tracing';
|
||||
import type {Attributes} from '@opentelemetry/api';
|
||||
|
||||
export const Logger = pino({
|
||||
level: Config.NODE_ENV === 'development' ? 'debug' : 'info',
|
||||
transport:
|
||||
Config.NODE_ENV === 'development'
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'HH:MM:ss.l',
|
||||
ignore: 'pid,hostname',
|
||||
messageFormat: '{msg}',
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
formatters: {
|
||||
level: (label) => ({level: label}),
|
||||
},
|
||||
errorKey: 'error',
|
||||
serializers: {
|
||||
reason: (value) => {
|
||||
if (value instanceof Error) {
|
||||
return pino.stdSerializers.err(value);
|
||||
}
|
||||
return value;
|
||||
export function createTracing(): TracingInterface {
|
||||
return {
|
||||
async withSpan<T>(options: SpanOptions, fn: () => Promise<T>): Promise<T> {
|
||||
return telemetryWithSpan(options.name, async () => fn(), {
|
||||
attributes: options.attributes as Attributes | undefined,
|
||||
});
|
||||
},
|
||||
},
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
base: {
|
||||
service: 'fluxer-media-proxy',
|
||||
},
|
||||
});
|
||||
addSpanEvent(name: string, attributes?: Record<string, unknown>): void {
|
||||
telemetryAddSpanEvent(name, attributes as Attributes | undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/*
|
||||
* 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 {Stream} from 'node:stream';
|
||||
import {HeadObjectCommand} from '@aws-sdk/client-s3';
|
||||
import type {Context} from 'hono';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import sharp from 'sharp';
|
||||
import {temporaryFile} from 'tempy';
|
||||
import * as v from 'valibot';
|
||||
import {Config} from '~/Config';
|
||||
import {Logger} from '~/Logger';
|
||||
import {toBodyData, toWebReadableStream} from '~/lib/BinaryUtils';
|
||||
import {createThumbnail} from '~/lib/FFmpegUtils';
|
||||
import {parseRange, setHeaders} from '~/lib/HttpUtils';
|
||||
import {processImage} from '~/lib/ImageProcessing';
|
||||
import type {InMemoryCoalescer} from '~/lib/InMemoryCoalescer';
|
||||
import type {HonoEnv} from '~/lib/MediaTypes';
|
||||
import {SUPPORTED_MIME_TYPES} from '~/lib/MediaTypes';
|
||||
import {validateMedia} from '~/lib/MediaValidation';
|
||||
import {getMediaCategory, getMimeType} from '~/lib/MimeTypeUtils';
|
||||
import {readS3Object, s3Client, streamS3Object} from '~/lib/S3Utils';
|
||||
import {ExternalQuerySchema} from '~/schemas/ValidationSchemas';
|
||||
|
||||
export const createAttachmentsHandler = (coalescer: InMemoryCoalescer) => {
|
||||
return async (ctx: Context<HonoEnv>): Promise<Response> => {
|
||||
const {channel_id, attachment_id, filename} = ctx.req.param();
|
||||
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: Config.AWS_S3_BUCKET_CDN,
|
||||
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 contentType: string;
|
||||
let lastModified: Date | undefined;
|
||||
|
||||
if (range) {
|
||||
const result = await readS3Object(Config.AWS_S3_BUCKET_CDN, key, range);
|
||||
assert(result.data instanceof Stream, 'Expected range request to return a stream');
|
||||
streamData = result.data;
|
||||
contentType = result.contentType;
|
||||
lastModified = result.lastModified;
|
||||
} else {
|
||||
const result = await streamS3Object(Config.AWS_S3_BUCKET_CDN, key);
|
||||
streamData = result.stream;
|
||||
contentType = result.contentType;
|
||||
lastModified = result.lastModified;
|
||||
}
|
||||
|
||||
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 cacheKey = `${key}_${width}_${height}_${format}_${quality}_${animated}`;
|
||||
|
||||
const result = await coalescer.coalesce(cacheKey, async () => {
|
||||
try {
|
||||
const {data, contentType: originalContentType} = await readS3Object(Config.AWS_S3_BUCKET_CDN, key);
|
||||
assert(data instanceof Buffer);
|
||||
|
||||
const mimeType = getMimeType(data, filename) || originalContentType;
|
||||
|
||||
if (mimeType && SUPPORTED_MIME_TYPES.has(mimeType)) {
|
||||
await validateMedia(data, filename, ctx);
|
||||
}
|
||||
|
||||
const mediaType = getMediaCategory(mimeType);
|
||||
|
||||
if (!mediaType) throw new HTTPException(400, {message: 'Invalid media type'});
|
||||
|
||||
if (mediaType === 'image') {
|
||||
const metadata = await sharp(data).metadata();
|
||||
const targetWidth = width ? Math.min(width, metadata.width || 0) : metadata.width || 0;
|
||||
const targetHeight = height ? Math.min(height, metadata.height || 0) : metadata.height || 0;
|
||||
|
||||
const image = await processImage({
|
||||
buffer: data,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
format: format || metadata.format || '',
|
||||
quality,
|
||||
animated: (mimeType.endsWith('gif') || mimeType.endsWith('webp')) && animated,
|
||||
});
|
||||
|
||||
const finalContentType = format ? getMimeType(Buffer.from(''), `image.${format}`) : originalContentType;
|
||||
return {data: image, contentType: finalContentType || 'application/octet-stream'};
|
||||
}
|
||||
|
||||
if (mediaType === 'video' && format) {
|
||||
const ext = mimeType.split('/')[1];
|
||||
const tempPath = temporaryFile({extension: ext});
|
||||
ctx.get('tempFiles').push(tempPath);
|
||||
await fs.writeFile(tempPath, data);
|
||||
|
||||
const thumbnailPath = await createThumbnail(tempPath);
|
||||
ctx.get('tempFiles').push(thumbnailPath);
|
||||
|
||||
const thumbnailData = await fs.readFile(thumbnailPath);
|
||||
const thumbMeta = await sharp(thumbnailData).metadata();
|
||||
|
||||
const targetWidth = width ? Math.min(width, thumbMeta.width || 0) : thumbMeta.width || 0;
|
||||
const targetHeight = height ? Math.min(height, thumbMeta.height || 0) : thumbMeta.height || 0;
|
||||
|
||||
const processedThumbnail = await processImage({
|
||||
buffer: thumbnailData,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
format,
|
||||
quality,
|
||||
animated: false,
|
||||
});
|
||||
|
||||
const contentType = getMimeType(Buffer.from(''), `image.${format}`);
|
||||
if (!contentType) throw new HTTPException(400, {message: 'Unsupported image format'});
|
||||
|
||||
return {data: processedThumbnail, contentType};
|
||||
}
|
||||
|
||||
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.replace(/\.[^.]+$/, `.${format}`) : filename;
|
||||
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));
|
||||
};
|
||||
};
|
||||
@@ -1,175 +0,0 @@
|
||||
/*
|
||||
* 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 {Context} from 'hono';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import sharp from 'sharp';
|
||||
import {temporaryFile} from 'tempy';
|
||||
import * as v from 'valibot';
|
||||
import {Config} from '~/Config';
|
||||
import {Logger} from '~/Logger';
|
||||
import {toBodyData} from '~/lib/BinaryUtils';
|
||||
import {createThumbnail} from '~/lib/FFmpegUtils';
|
||||
import {parseRange, setHeaders} from '~/lib/HttpUtils';
|
||||
import {processImage} from '~/lib/ImageProcessing';
|
||||
import type {InMemoryCoalescer} from '~/lib/InMemoryCoalescer';
|
||||
import type {ErrorType, HonoEnv} from '~/lib/MediaTypes';
|
||||
import {validateMedia} from '~/lib/MediaValidation';
|
||||
import * as metrics from '~/lib/MetricsClient';
|
||||
import {generateFilename, getMediaCategory, getMimeType} from '~/lib/MimeTypeUtils';
|
||||
import {streamToBuffer} from '~/lib/S3Utils';
|
||||
import {ExternalQuerySchema} from '~/schemas/ValidationSchemas';
|
||||
import * as FetchUtils from '~/utils/FetchUtils';
|
||||
import * as MediaProxyUtils from '~/utils/MediaProxyUtils';
|
||||
|
||||
const 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';
|
||||
};
|
||||
|
||||
const fetchAndValidate = async (
|
||||
url: string,
|
||||
ctx: Context<HonoEnv>,
|
||||
): Promise<{buffer: Buffer; mimeType: string; filename: string}> => {
|
||||
try {
|
||||
const response = await FetchUtils.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, ctx);
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
export const createExternalMediaHandler = (coalescer: InMemoryCoalescer) => {
|
||||
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('/');
|
||||
|
||||
if (!signature || !proxyUrlPath) throw new HTTPException(400);
|
||||
if (!MediaProxyUtils.verifySignature(proxyUrlPath, signature, Config.SECRET_KEY)) {
|
||||
throw new HTTPException(401);
|
||||
}
|
||||
|
||||
const cacheKey = `${proxyUrlPath}_${signature}_${width}_${height}_${format}_${quality}_${animated}`;
|
||||
|
||||
const result = await coalescer.coalesce(cacheKey, async () => {
|
||||
try {
|
||||
const actualUrl = MediaProxyUtils.reconstructOriginalURL(proxyUrlPath);
|
||||
const {buffer, mimeType} = await fetchAndValidate(actualUrl, ctx);
|
||||
const mediaType = getMediaCategory(mimeType);
|
||||
|
||||
if (!mediaType) throw new HTTPException(400, {message: 'Invalid media type'});
|
||||
|
||||
if (mediaType === 'image') {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
const targetWidth = width ? Math.min(width, metadata.width || 0) : metadata.width || 0;
|
||||
const targetHeight = height ? Math.min(height, metadata.height || 0) : metadata.height || 0;
|
||||
|
||||
const image = await processImage({
|
||||
buffer,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
format: format || metadata.format || '',
|
||||
quality,
|
||||
animated: (mimeType.endsWith('gif') || mimeType.endsWith('webp')) && animated,
|
||||
});
|
||||
|
||||
const contentType = format ? getMimeType(Buffer.from(''), `image.${format}`) : mimeType;
|
||||
|
||||
return {data: image, contentType: contentType || 'application/octet-stream'};
|
||||
}
|
||||
|
||||
if (mediaType === 'video' && format) {
|
||||
const ext = mimeType.split('/')[1];
|
||||
const tempPath = temporaryFile({extension: ext});
|
||||
ctx.get('tempFiles').push(tempPath);
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
|
||||
const thumbnailPath = await createThumbnail(tempPath);
|
||||
ctx.get('tempFiles').push(thumbnailPath);
|
||||
|
||||
const thumbnailData = await fs.readFile(thumbnailPath);
|
||||
const thumbMeta = await sharp(thumbnailData).metadata();
|
||||
|
||||
const targetWidth = width ? Math.min(width, thumbMeta.width || 0) : thumbMeta.width || 0;
|
||||
const targetHeight = height ? Math.min(height, thumbMeta.height || 0) : thumbMeta.height || 0;
|
||||
|
||||
const processedThumbnail = await processImage({
|
||||
buffer: thumbnailData,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
format,
|
||||
quality,
|
||||
animated: false,
|
||||
});
|
||||
|
||||
const contentType = getMimeType(Buffer.from(''), `image.${format}`);
|
||||
if (!contentType) throw new HTTPException(400, {message: 'Unsupported image format'});
|
||||
|
||||
return {data: processedThumbnail, contentType};
|
||||
}
|
||||
|
||||
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'});
|
||||
}
|
||||
});
|
||||
|
||||
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));
|
||||
};
|
||||
};
|
||||
@@ -1,187 +0,0 @@
|
||||
/*
|
||||
* 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 type {Context} from 'hono';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import sharp from 'sharp';
|
||||
import * as v from 'valibot';
|
||||
import {Config} from '~/Config';
|
||||
import {toBodyData} from '~/lib/BinaryUtils';
|
||||
import {parseRange, setHeaders} from '~/lib/HttpUtils';
|
||||
import {processImage} from '~/lib/ImageProcessing';
|
||||
import type {InMemoryCoalescer} from '~/lib/InMemoryCoalescer';
|
||||
import type {HonoEnv} from '~/lib/MediaTypes';
|
||||
import {MEDIA_TYPES} from '~/lib/MediaTypes';
|
||||
import {getMimeType} from '~/lib/MimeTypeUtils';
|
||||
import {readS3Object} from '~/lib/S3Utils';
|
||||
import {ImageParamSchema, ImageQuerySchema} from '~/schemas/ValidationSchemas';
|
||||
|
||||
const stripAnimationPrefix = (hash: string) => (hash.startsWith('a_') ? hash.substring(2) : hash);
|
||||
|
||||
const processImageRequest = async (params: {
|
||||
coalescer: InMemoryCoalescer;
|
||||
ctx: Context<HonoEnv>;
|
||||
cacheKey: string;
|
||||
s3Key: string;
|
||||
ext: string;
|
||||
aspectRatio: number;
|
||||
size: string;
|
||||
quality: string;
|
||||
animated: boolean;
|
||||
}): Promise<Response> => {
|
||||
const {coalescer, ctx, cacheKey, s3Key, ext, aspectRatio, size, quality, animated} = params;
|
||||
|
||||
const result = await coalescer.coalesce(cacheKey, async () => {
|
||||
const {data} = await readS3Object(Config.AWS_S3_BUCKET_CDN, 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 image = await processImage({
|
||||
buffer: data,
|
||||
width,
|
||||
height,
|
||||
format: ext,
|
||||
quality,
|
||||
animated: ext === 'gif' || (ext === 'webp' && animated),
|
||||
});
|
||||
|
||||
const mimeType = getMimeType(Buffer.from(''), `image.${ext}`) || '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 const createImageRouteHandler = (coalescer: InMemoryCoalescer) => {
|
||||
return async (ctx: Context<HonoEnv>, pathPrefix: string, aspectRatio = 0): Promise<Response> => {
|
||||
const {id, filename} = v.parse(ImageParamSchema, ctx.req.param());
|
||||
const {size, quality, animated} = v.parse(ImageQuerySchema, ctx.req.query());
|
||||
|
||||
const parts = filename.split('.');
|
||||
if (parts.length !== 2 || !MEDIA_TYPES.IMAGE.extensions.includes(parts[1])) {
|
||||
throw new HTTPException(400);
|
||||
}
|
||||
|
||||
const [hash, ext] = parts;
|
||||
const strippedHash = stripAnimationPrefix(hash);
|
||||
const cacheKey = `${pathPrefix}_${id}_${hash}_${ext}_${size}_${quality}_${aspectRatio}_${animated}`;
|
||||
const s3Key = `${pathPrefix}/${id}/${strippedHash}`;
|
||||
|
||||
return processImageRequest({coalescer, ctx, cacheKey, s3Key, ext, aspectRatio, size, quality, animated});
|
||||
};
|
||||
};
|
||||
|
||||
export const createGuildMemberImageRouteHandler = (coalescer: InMemoryCoalescer) => {
|
||||
return async (ctx: Context<HonoEnv>, pathPrefix: string, aspectRatio = 0): Promise<Response> => {
|
||||
const {guild_id, user_id, filename} = ctx.req.param();
|
||||
const {size, quality, animated} = v.parse(ImageQuerySchema, ctx.req.query());
|
||||
|
||||
const parts = filename.split('.');
|
||||
if (parts.length !== 2 || !MEDIA_TYPES.IMAGE.extensions.includes(parts[1])) {
|
||||
throw new HTTPException(400);
|
||||
}
|
||||
|
||||
const [hash, ext] = parts;
|
||||
const strippedHash = stripAnimationPrefix(hash);
|
||||
const cacheKey = `${pathPrefix}_${guild_id}_${user_id}_${hash}_${ext}_${size}_${quality}_${aspectRatio}_${animated}`;
|
||||
const s3Key = `guilds/${guild_id}/users/${user_id}/${pathPrefix}/${strippedHash}`;
|
||||
|
||||
return processImageRequest({coalescer, ctx, cacheKey, s3Key, ext, aspectRatio, size, quality, animated});
|
||||
};
|
||||
};
|
||||
|
||||
const processSimpleImageRequest = async (params: {
|
||||
coalescer: InMemoryCoalescer;
|
||||
ctx: Context<HonoEnv>;
|
||||
cacheKey: string;
|
||||
s3Key: string;
|
||||
ext: string;
|
||||
aspectRatio: number;
|
||||
size: string;
|
||||
quality: string;
|
||||
animated: boolean;
|
||||
}): Promise<Response> => {
|
||||
const {coalescer, ctx, cacheKey, s3Key, ext, aspectRatio, size, quality, animated} = params;
|
||||
|
||||
const result = await coalescer.coalesce(cacheKey, async () => {
|
||||
const {data} = await readS3Object(Config.AWS_S3_BUCKET_CDN, 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 shouldAnimate = ext === 'gif' ? true : ext === 'webp' && animated;
|
||||
const image = await sharp(data, {animated: shouldAnimate})
|
||||
.resize(width, height, {
|
||||
fit: 'contain',
|
||||
background: {r: 255, g: 255, b: 255, alpha: 0},
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.toFormat(ext as keyof sharp.FormatEnum, {
|
||||
quality: quality === 'high' ? 80 : quality === 'low' ? 20 : 100,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
const mimeType = getMimeType(Buffer.from(''), `image.${ext}`) || '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 const createSimpleImageRouteHandler = (coalescer: InMemoryCoalescer) => {
|
||||
return async (ctx: Context<HonoEnv>, pathPrefix: string, aspectRatio = 0): Promise<Response> => {
|
||||
const {id} = ctx.req.param();
|
||||
const {size, quality, animated} = v.parse(ImageQuerySchema, ctx.req.query());
|
||||
|
||||
const parts = id.split('.');
|
||||
if (parts.length !== 2 || !MEDIA_TYPES.IMAGE.extensions.includes(parts[1])) {
|
||||
throw new HTTPException(400);
|
||||
}
|
||||
|
||||
const [filename, ext] = parts;
|
||||
const cacheKey = `${pathPrefix}_${filename}_${ext}_${size}_${quality}_${aspectRatio}_${animated}`;
|
||||
const s3Key = `${pathPrefix}/${filename}`;
|
||||
|
||||
return processSimpleImageRequest({coalescer, ctx, cacheKey, s3Key, ext, aspectRatio, size, quality, animated});
|
||||
};
|
||||
};
|
||||
@@ -1,167 +0,0 @@
|
||||
/*
|
||||
* 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 {Context} from 'hono';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import sharp from 'sharp';
|
||||
import {temporaryFile} from 'tempy';
|
||||
import {Config} from '~/Config';
|
||||
import {Logger} from '~/Logger';
|
||||
import {createThumbnail} from '~/lib/FFmpegUtils';
|
||||
import type {InMemoryCoalescer} from '~/lib/InMemoryCoalescer';
|
||||
import type {HonoEnv} from '~/lib/MediaTypes';
|
||||
import {processMetadata, validateMedia} from '~/lib/MediaValidation';
|
||||
import {generateFilename, getMimeType} from '~/lib/MimeTypeUtils';
|
||||
import type {NSFWDetectionService} from '~/lib/NSFWDetectionService';
|
||||
import {readS3Object, streamToBuffer} from '~/lib/S3Utils';
|
||||
import * as FetchUtils from '~/utils/FetchUtils';
|
||||
|
||||
type MediaProxyMetadataRequest =
|
||||
| {type: 'external'; url: string; with_base64?: boolean; isNSFWAllowed: boolean}
|
||||
| {type: 'upload'; upload_filename: string; isNSFWAllowed: boolean}
|
||||
| {type: 'base64'; base64: string; isNSFWAllowed: boolean}
|
||||
| {type: 's3'; bucket: string; key: string; with_base64?: boolean; isNSFWAllowed: boolean};
|
||||
|
||||
export const handleMetadataRequest = (coalescer: InMemoryCoalescer, nsfwDetectionService: NSFWDetectionService) => {
|
||||
return async (ctx: Context<HonoEnv>) => {
|
||||
const request = await ctx.req.json<MediaProxyMetadataRequest>();
|
||||
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 {buffer, filename} = await (async () => {
|
||||
switch (request.type) {
|
||||
case 'base64':
|
||||
return {buffer: Buffer.from(request.base64!, 'base64'), filename: undefined};
|
||||
|
||||
case 'upload': {
|
||||
const {data} = await readS3Object(Config.AWS_S3_BUCKET_UPLOADS, request.upload_filename!);
|
||||
assert(data instanceof Buffer);
|
||||
return {buffer: data, filename: request.upload_filename};
|
||||
}
|
||||
|
||||
case 's3': {
|
||||
const {data} = await readS3Object(request.bucket!, request.key!);
|
||||
assert(data instanceof Buffer);
|
||||
const filename = request.key!.substring(request.key!.lastIndexOf('/') + 1);
|
||||
return {buffer: data, filename: filename || undefined};
|
||||
}
|
||||
|
||||
case 'external': {
|
||||
try {
|
||||
const response = await FetchUtils.sendRequest({url: request.url!});
|
||||
if (response.status !== 200) throw new HTTPException(400, {message: 'Failed to fetch media'});
|
||||
|
||||
const url = new URL(request.url!);
|
||||
const filename = url.pathname.substring(url.pathname.lastIndexOf('/') + 1);
|
||||
return {buffer: await streamToBuffer(response.stream), filename: filename || undefined};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'isExpected' in error && error.isExpected) {
|
||||
throw new HTTPException(400, {message: `Unable to fetch media: ${error.message}`});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw new HTTPException(400, {message: 'Invalid request type'});
|
||||
}
|
||||
})();
|
||||
|
||||
let effectiveFilename = filename;
|
||||
if (!effectiveFilename && request.type === 'base64') {
|
||||
try {
|
||||
const detectedMime = getMimeType(buffer);
|
||||
if (detectedMime) {
|
||||
effectiveFilename = generateFilename(detectedMime);
|
||||
} else {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
if (metadata.format) effectiveFilename = `image.${metadata.format}`;
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Failed to detect format of base64 data');
|
||||
throw new HTTPException(400, {message: 'Invalid or corrupt media data'});
|
||||
}
|
||||
}
|
||||
|
||||
if (!effectiveFilename) {
|
||||
throw new HTTPException(400, {message: 'Cannot determine file type'});
|
||||
}
|
||||
|
||||
const mimeType = await validateMedia(buffer, effectiveFilename, ctx);
|
||||
const metadata = await processMetadata(ctx, 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});
|
||||
ctx.get('tempFiles').push(tempPath);
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
|
||||
const thumbnailPath = await createThumbnail(tempPath);
|
||||
ctx.get('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 ctx.json({
|
||||
...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,
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* 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 type {Context} from 'hono';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import {Config} from '~/Config';
|
||||
import {toBodyData} from '~/lib/BinaryUtils';
|
||||
import {setHeaders} from '~/lib/HttpUtils';
|
||||
import type {HonoEnv} from '~/lib/MediaTypes';
|
||||
import {readS3Object} from '~/lib/S3Utils';
|
||||
|
||||
export const handleStaticProxyRequest = async (ctx: Context<HonoEnv>): Promise<Response> => {
|
||||
const bucket = Config.AWS_S3_BUCKET_STATIC;
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
/*
|
||||
* 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 type {Context} from 'hono';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import sharp from 'sharp';
|
||||
import * as v from 'valibot';
|
||||
import {Config} from '~/Config';
|
||||
import {toBodyData} from '~/lib/BinaryUtils';
|
||||
import {parseRange, setHeaders} from '~/lib/HttpUtils';
|
||||
import type {InMemoryCoalescer} from '~/lib/InMemoryCoalescer';
|
||||
import type {HonoEnv} from '~/lib/MediaTypes';
|
||||
import {getMimeType} from '~/lib/MimeTypeUtils';
|
||||
import {readS3Object} from '~/lib/S3Utils';
|
||||
import {ImageQuerySchema} from '~/schemas/ValidationSchemas';
|
||||
|
||||
const STICKER_EXTENSIONS = ['gif', 'webp'];
|
||||
|
||||
const processStickerRequest = async (params: {
|
||||
coalescer: InMemoryCoalescer;
|
||||
ctx: Context<HonoEnv>;
|
||||
cacheKey: string;
|
||||
s3Key: string;
|
||||
ext: string;
|
||||
size: string;
|
||||
quality: string;
|
||||
animated: boolean;
|
||||
}): Promise<Response> => {
|
||||
const {coalescer, ctx, cacheKey, s3Key, ext, size, quality, animated} = params;
|
||||
|
||||
const result = await coalescer.coalesce(cacheKey, async () => {
|
||||
const {data} = await readS3Object(Config.AWS_S3_BUCKET_CDN, 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 shouldAnimate = ext === 'gif' ? true : ext === 'webp' && animated;
|
||||
const image = await sharp(data, {animated: shouldAnimate})
|
||||
.resize(width, height, {
|
||||
fit: 'contain',
|
||||
background: {r: 255, g: 255, b: 255, alpha: 0},
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.toFormat(ext as keyof sharp.FormatEnum, {
|
||||
quality: quality === 'high' ? 80 : quality === 'low' ? 20 : 100,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
const mimeType = getMimeType(Buffer.from(''), `image.${ext}`) || '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 const createStickerRouteHandler = (coalescer: InMemoryCoalescer) => {
|
||||
return async (ctx: Context<HonoEnv>): Promise<Response> => {
|
||||
const {id} = ctx.req.param();
|
||||
const {size, quality, animated} = v.parse(ImageQuerySchema, ctx.req.query());
|
||||
|
||||
const parts = id.split('.');
|
||||
if (parts.length !== 2 || !STICKER_EXTENSIONS.includes(parts[1])) {
|
||||
throw new HTTPException(400);
|
||||
}
|
||||
|
||||
const [filename, ext] = parts;
|
||||
const cacheKey = `stickers_${filename}_${ext}_${size}_${quality}_${animated}`;
|
||||
const s3Key = `stickers/${filename}`;
|
||||
|
||||
return processStickerRequest({coalescer, ctx, cacheKey, s3Key, ext, size, quality, animated});
|
||||
};
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* 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 type {Context} from 'hono';
|
||||
import {Config} from '~/Config';
|
||||
import {toBodyData, toWebReadableStream} from '~/lib/BinaryUtils';
|
||||
import type {HonoEnv} from '~/lib/MediaTypes';
|
||||
import {headS3Object, readS3Object} from '~/lib/S3Utils';
|
||||
|
||||
const THEME_ID_PATTERN = /^[a-f0-9]{16}$/;
|
||||
|
||||
export async function handleThemeHeadRequest(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 headS3Object(Config.AWS_S3_BUCKET_CDN, `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 async function handleThemeRequest(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 readS3Object(Config.AWS_S3_BUCKET_CDN, `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));
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
* 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 {Context} from 'hono';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import sharp from 'sharp';
|
||||
import {temporaryFile} from 'tempy';
|
||||
import * as v from 'valibot';
|
||||
import {Config} from '~/Config';
|
||||
import {Logger} from '~/Logger';
|
||||
import {toBodyData} from '~/lib/BinaryUtils';
|
||||
import {createThumbnail} from '~/lib/FFmpegUtils';
|
||||
import type {HonoEnv} from '~/lib/MediaTypes';
|
||||
import {getMediaCategory, getMimeType} from '~/lib/MimeTypeUtils';
|
||||
import {readS3Object} from '~/lib/S3Utils';
|
||||
|
||||
const ThumbnailRequestSchema = v.object({
|
||||
type: v.literal('upload'),
|
||||
upload_filename: v.string(),
|
||||
});
|
||||
|
||||
export const handleThumbnailRequest = async (ctx: Context<HonoEnv>): Promise<Response> => {
|
||||
try {
|
||||
const body = await ctx.req.json();
|
||||
const {upload_filename} = v.parse(ThumbnailRequestSchema, body);
|
||||
|
||||
const {data} = await readS3Object(Config.AWS_S3_BUCKET_UPLOADS, 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);
|
||||
}
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
/*
|
||||
* 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 const 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 const toWebReadableStream = (stream: Stream): WebReadableStream<Uint8Array> => {
|
||||
return Readable.toWeb(stream as Readable);
|
||||
};
|
||||
@@ -1,274 +0,0 @@
|
||||
/*
|
||||
* 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 {Logger} from '~/Logger';
|
||||
import {sendRequest} from '~/utils/FetchUtils';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export class CloudflareIPService {
|
||||
private cidrs: Array<CidrEntry> = [];
|
||||
private refreshTimer?: NodeJS.Timeout;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.refreshNow();
|
||||
|
||||
this.refreshTimer = setInterval(() => {
|
||||
this.refreshNow().catch((error) => {
|
||||
Logger.error({error}, 'Failed to refresh Cloudflare IP ranges; keeping existing ranges');
|
||||
});
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
this.refreshTimer.unref();
|
||||
}
|
||||
|
||||
isFromCloudflare(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 IP list refresh returned no valid ranges');
|
||||
}
|
||||
|
||||
this.cidrs = nextCidrs;
|
||||
Logger.info({count: this.cidrs.length}, 'Refreshed Cloudflare IP ranges');
|
||||
}
|
||||
|
||||
private async fetchRanges(url: string): Promise<Array<string>> {
|
||||
const response = await sendRequest({url, method: 'GET'});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to download Cloudflare IPs from ${url} (status ${response.status})`);
|
||||
}
|
||||
|
||||
const text = await CloudflareIPService.streamToString(response.stream);
|
||||
return text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
private static async streamToString(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
const chunks: Array<Buffer> = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
}
|
||||
|
||||
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 (!/^\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, tailPart] = parts;
|
||||
} 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++) {
|
||||
bytes[i * 2] = (groups[i] >> 8) & 0xff;
|
||||
bytes[i * 2 + 1] = groups[i] & 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;
|
||||
network[fullBytes] &= 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) {
|
||||
const mask = (0xff << (8 - remainingBits)) & 0xff;
|
||||
const idx = bytesToCheck;
|
||||
if (((ip.bytes[idx] ?? 0) & mask) !== ((cidr.network[idx] ?? 0) & mask)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* 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 {Context} from 'hono';
|
||||
import {temporaryFile} from 'tempy';
|
||||
import {Logger} from '~/Logger';
|
||||
import {type FFprobeStream, ffprobe} from '~/lib/FFmpegUtils';
|
||||
import type {HonoEnv} from '~/lib/MediaTypes';
|
||||
import {MEDIA_TYPES} from '~/lib/MediaTypes';
|
||||
|
||||
interface AudioStream extends FFprobeStream {
|
||||
codec_type: 'audio';
|
||||
}
|
||||
|
||||
const 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;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const 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 const validateCodecs = async (buffer: Buffer, filename: string, ctx: Context<HonoEnv>): Promise<boolean> => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
if (!ext) return false;
|
||||
|
||||
const tempPath = temporaryFile({extension: ext});
|
||||
ctx.get('tempFiles').push(tempPath);
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* 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 {promisify} from 'node:util';
|
||||
import {temporaryFile} from 'tempy';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const 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 const ffprobe = async (path: string): Promise<FFprobeResult> => {
|
||||
const {stdout} = await execFileAsync('ffprobe', [
|
||||
'-v',
|
||||
'quiet',
|
||||
'-print_format',
|
||||
'json',
|
||||
'-show_format',
|
||||
'-show_streams',
|
||||
path,
|
||||
]);
|
||||
return parseProbeOutput(stdout);
|
||||
};
|
||||
|
||||
export const hasVideoStream = async (path: string): Promise<boolean> => {
|
||||
const probeResult = await ffprobe(path);
|
||||
return probeResult.streams?.some((stream) => stream.codec_type === 'video') ?? false;
|
||||
};
|
||||
|
||||
export const createThumbnail = async (videoPath: string): Promise<string> => {
|
||||
const hasVideo = await hasVideoStream(videoPath);
|
||||
if (!hasVideo) {
|
||||
throw new Error('File does not contain a video stream');
|
||||
}
|
||||
const thumbnailPath = temporaryFile({extension: 'jpg'});
|
||||
await execFileAsync('ffmpeg', ['-i', videoPath, '-vf', 'select=eq(n\\,0)', '-vframes', '1', thumbnailPath]);
|
||||
return thumbnailPath;
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* 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 const 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 const 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());
|
||||
}
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* 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 sharp from 'sharp';
|
||||
import {rgbaToThumbHash} from 'thumbhash';
|
||||
|
||||
export const generatePlaceholder = async (imageBuffer: Buffer): Promise<string> => {
|
||||
const {data, info} = await sharp(imageBuffer)
|
||||
.blur(10)
|
||||
.resize(100, 100, {fit: 'inside', withoutEnlargement: true})
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({resolveWithObject: true});
|
||||
|
||||
if (data.length !== info.width * info.height * 4) {
|
||||
throw new Error('Unexpected data length');
|
||||
}
|
||||
|
||||
const placeholder = rgbaToThumbHash(info.width, info.height, data);
|
||||
return Buffer.from(placeholder).toString('base64');
|
||||
};
|
||||
|
||||
export const processImage = async (opts: {
|
||||
buffer: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
format: string;
|
||||
quality: string;
|
||||
animated: boolean;
|
||||
}): Promise<Buffer> => {
|
||||
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);
|
||||
|
||||
return sharp(opts.buffer, {
|
||||
animated: opts.format === 'gif' || (opts.format === 'webp' && opts.animated),
|
||||
})
|
||||
.resize(resizeWidth, resizeHeight, {
|
||||
fit: 'cover',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.toFormat(opts.format as keyof sharp.FormatEnum, {
|
||||
quality: opts.quality === 'high' ? 80 : opts.quality === 'low' ? 20 : 100,
|
||||
})
|
||||
.toBuffer();
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/*
|
||||
* 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 {Context} from 'hono';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import sharp from 'sharp';
|
||||
import {temporaryFile} from 'tempy';
|
||||
import {validateCodecs} from '~/lib/CodecValidation';
|
||||
import {createThumbnail, ffprobe} from '~/lib/FFmpegUtils';
|
||||
import {generatePlaceholder} from '~/lib/ImageProcessing';
|
||||
import type {HonoEnv} from '~/lib/MediaTypes';
|
||||
import {generateFilename, getMediaCategory, getMimeType} from '~/lib/MimeTypeUtils';
|
||||
|
||||
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 const validateMedia = async (buffer: Buffer, filename: string, ctx: Context<HonoEnv>): Promise<string> => {
|
||||
const mimeType = getMimeType(buffer, filename);
|
||||
|
||||
if (!mimeType) {
|
||||
throw new HTTPException(400, {message: 'Unsupported file format'});
|
||||
}
|
||||
|
||||
const mediaType = getMediaCategory(mimeType);
|
||||
|
||||
if (!mediaType) {
|
||||
throw new HTTPException(400, {message: 'Invalid media type'});
|
||||
}
|
||||
|
||||
if (mediaType !== 'image') {
|
||||
const validationFilename = filename.includes('.') ? filename : generateFilename(mimeType, filename);
|
||||
const isValid = await validateCodecs(buffer, validationFilename, ctx);
|
||||
if (!isValid) {
|
||||
throw new HTTPException(400, {message: 'File contains unsupported or non-web-compatible codecs'});
|
||||
}
|
||||
}
|
||||
|
||||
return mimeType;
|
||||
};
|
||||
|
||||
const toNumericField = (value: string | number | undefined): number => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
export const processMetadata = async (
|
||||
ctx: Context<HonoEnv>,
|
||||
mimeType: string,
|
||||
buffer: Buffer,
|
||||
): Promise<MediaMetadata> => {
|
||||
const mediaType = getMediaCategory(mimeType);
|
||||
|
||||
if (mediaType === 'image') {
|
||||
const {format = '', size = 0, width = 0, height = 0, pages} = await sharp(buffer).metadata();
|
||||
|
||||
if (width > 9500 || height > 9500) throw new HTTPException(400);
|
||||
|
||||
return {
|
||||
format,
|
||||
size,
|
||||
width,
|
||||
height,
|
||||
placeholder: await generatePlaceholder(buffer),
|
||||
animated: pages ? pages > 1 : false,
|
||||
} satisfies ImageMetadata;
|
||||
}
|
||||
|
||||
const ext = mimeType.split('/')[1];
|
||||
const tempPath = temporaryFile({extension: ext});
|
||||
ctx.get('tempFiles').push(tempPath);
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
|
||||
if (mediaType === 'video') {
|
||||
const thumbnailPath = await createThumbnail(tempPath);
|
||||
ctx.get('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 || '',
|
||||
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 || '',
|
||||
size: toNumericField(probeData.format?.size),
|
||||
duration: Math.ceil(Number(probeData.format?.duration || 0)),
|
||||
} satisfies AudioMetadata;
|
||||
}
|
||||
|
||||
throw new HTTPException(400, {message: 'Unsupported media type'});
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
/*
|
||||
* 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 process from 'node:process';
|
||||
|
||||
const METRICS_HOST = process.env.FLUXER_METRICS_HOST;
|
||||
const MAX_RETRIES = 1;
|
||||
|
||||
interface CounterParams {
|
||||
name: string;
|
||||
dimensions?: Record<string, string>;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
interface HistogramParams {
|
||||
name: string;
|
||||
dimensions?: Record<string, string>;
|
||||
valueMs: number;
|
||||
}
|
||||
|
||||
interface GaugeParams {
|
||||
name: string;
|
||||
dimensions?: Record<string, string>;
|
||||
value: number;
|
||||
}
|
||||
|
||||
async function sendMetric(url: string, body: string, attempt: number): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!response.ok && attempt < MAX_RETRIES) {
|
||||
await sendMetric(url, body, attempt + 1);
|
||||
} else if (!response.ok) {
|
||||
console.error(
|
||||
`[MetricsClient] Failed to send metric after ${attempt + 1} attempts: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await sendMetric(url, body, attempt + 1);
|
||||
} else {
|
||||
console.error(`[MetricsClient] Failed to send metric after ${attempt + 1} attempts:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fireAndForget(path: string, body: unknown): void {
|
||||
if (!METRICS_HOST) return;
|
||||
|
||||
const url = `http://${METRICS_HOST}${path}`;
|
||||
const jsonBody = JSON.stringify(body);
|
||||
sendMetric(url, jsonBody, 0).catch(() => {});
|
||||
}
|
||||
|
||||
export function counter({name, dimensions = {}, value = 1}: CounterParams): void {
|
||||
fireAndForget('/metrics/counter', {name, dimensions, value});
|
||||
}
|
||||
|
||||
export function histogram({name, dimensions = {}, valueMs}: HistogramParams): void {
|
||||
fireAndForget('/metrics/histogram', {name, dimensions, value_ms: valueMs});
|
||||
}
|
||||
|
||||
export function gauge({name, dimensions = {}, value}: GaugeParams): void {
|
||||
fireAndForget('/metrics/gauge', {name, dimensions, value});
|
||||
}
|
||||
|
||||
export function isEnabled(): boolean {
|
||||
return !!METRICS_HOST;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* 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 {filetypeinfo} from 'magic-bytes.js';
|
||||
import {Logger} from '~/Logger';
|
||||
import {SUPPORTED_EXTENSIONS, SUPPORTED_MIME_TYPES, type SupportedExtension} from '~/lib/MediaTypes';
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export 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}`;
|
||||
};
|
||||
|
||||
export const getMediaCategory = (mimeType: string): string | null => {
|
||||
const category = mimeType.split('/')[0];
|
||||
return ['image', 'video', 'audio'].includes(category) ? category : null;
|
||||
};
|
||||
@@ -1,108 +0,0 @@
|
||||
/*
|
||||
* 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';
|
||||
import {Config} from '../Config';
|
||||
|
||||
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;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const modelPath =
|
||||
Config.NODE_ENV === 'production' ? '/opt/data/model.onnx' : path.join(process.cwd(), 'data', 'model.onnx');
|
||||
|
||||
const modelBuffer = await fs.readFile(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 predictionMap = {
|
||||
drawing: predictions[0],
|
||||
// NOTE: hentai: predictions[1], gives false positives
|
||||
hentai: 0,
|
||||
neutral: predictions[2],
|
||||
porn: predictions[3],
|
||||
sexy: predictions[4],
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
for (let i = 0; i < imageBuffer.length; i++) {
|
||||
float32Array[i] = imageBuffer[i] / 255.0;
|
||||
}
|
||||
|
||||
return float32Array;
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
/*
|
||||
* 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 {HTTPException} from 'hono/http-exception';
|
||||
import {Config} from '~/Config';
|
||||
import * as metrics from '~/lib/MetricsClient';
|
||||
|
||||
const MAX_STREAM_BYTES = 500 * 1024 * 1024;
|
||||
|
||||
export const s3Client = new S3Client({
|
||||
endpoint: Config.AWS_S3_ENDPOINT,
|
||||
region: 'us-east-1',
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: Config.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: Config.AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
});
|
||||
|
||||
export interface S3HeadResult {
|
||||
contentLength: number;
|
||||
contentType: string;
|
||||
lastModified?: Date;
|
||||
}
|
||||
|
||||
export 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 s3Client.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;
|
||||
}
|
||||
};
|
||||
|
||||
export const readS3Object = async (
|
||||
bucket: string,
|
||||
key: string,
|
||||
range?: {start: number; end: number},
|
||||
client: S3Client = s3Client,
|
||||
) => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
export 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 s3Client.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;
|
||||
}
|
||||
};
|
||||
|
||||
export const streamToBuffer = async (stream: NodeJS.ReadableStream): Promise<Buffer> => {
|
||||
const chunks: Array<Buffer> = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalSize += buffer.length;
|
||||
if (totalSize > MAX_STREAM_BYTES) throw new HTTPException(413);
|
||||
chunks.push(buffer);
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* 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 {createMiddleware} from 'hono/factory';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import {Config} from '~/Config';
|
||||
import type {HonoEnv} from '~/lib/MediaTypes';
|
||||
|
||||
export const InternalNetworkRequired = createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||
const authHeader = ctx.req.header('Authorization');
|
||||
const expectedAuth = `Bearer ${Config.SECRET_KEY}`;
|
||||
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();
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* 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 {createMiddleware} from 'hono/factory';
|
||||
import {HTTPException} from 'hono/http-exception';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {CloudflareIPService} from '~/lib/CloudflareIPService';
|
||||
import type {HonoEnv} from '~/lib/MediaTypes';
|
||||
|
||||
interface CloudflareFirewallOptions {
|
||||
enabled: boolean;
|
||||
exemptPaths?: Array<string>;
|
||||
}
|
||||
|
||||
export const createCloudflareFirewall = (
|
||||
ipService: CloudflareIPService,
|
||||
{enabled, exemptPaths = ['/_health', '/_metadata']}: CloudflareFirewallOptions,
|
||||
) =>
|
||||
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.isFromCloudflare(connectingIP)) {
|
||||
Logger.warn({connectingIP, path}, 'Rejected request from non-Cloudflare IP');
|
||||
throw new HTTPException(403, {message: 'Forbidden'});
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
/*
|
||||
* 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 {MiddlewareHandler} from 'hono';
|
||||
import type {ErrorContext, ErrorType} from '~/lib/MediaTypes';
|
||||
import * as metrics from '~/lib/MetricsClient';
|
||||
|
||||
const getRouteFromPath = (path: string): string | null => {
|
||||
if (path === '/_health') 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';
|
||||
};
|
||||
|
||||
const 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 const metricsMiddleware: MiddlewareHandler = 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;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
* 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 * 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([
|
||||
'16',
|
||||
'20',
|
||||
'22',
|
||||
'24',
|
||||
'28',
|
||||
'32',
|
||||
'40',
|
||||
'44',
|
||||
'48',
|
||||
'56',
|
||||
'60',
|
||||
'64',
|
||||
'80',
|
||||
'96',
|
||||
'100',
|
||||
'128',
|
||||
'160',
|
||||
'240',
|
||||
'256',
|
||||
'300',
|
||||
'320',
|
||||
'480',
|
||||
'512',
|
||||
'600',
|
||||
'640',
|
||||
'1024',
|
||||
'1280',
|
||||
'1536',
|
||||
'2048',
|
||||
'3072',
|
||||
'4096',
|
||||
]),
|
||||
'128',
|
||||
),
|
||||
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'),
|
||||
),
|
||||
});
|
||||
@@ -1,280 +0,0 @@
|
||||
/*
|
||||
* 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 {errors, request} from 'undici';
|
||||
import {FLUXER_USER_AGENT} from '~/Constants';
|
||||
import type {ErrorType} from '~/lib/MediaTypes';
|
||||
import * as metrics from '~/lib/MetricsClient';
|
||||
|
||||
type RequestResult = Awaited<ReturnType<typeof request>>;
|
||||
type ResponseStream = RequestResult['body'];
|
||||
|
||||
interface RequestOptions {
|
||||
url: string;
|
||||
method?: 'GET' | 'POST' | 'HEAD';
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface StreamResponse {
|
||||
stream: ResponseStream;
|
||||
headers: Headers;
|
||||
status: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface RedirectResult {
|
||||
body: ResponseStream;
|
||||
headers: Record<string, string | Array<string>>;
|
||||
statusCode: number;
|
||||
finalUrl: string;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noStaticOnlyClass: this is fine
|
||||
class HttpClient {
|
||||
private static readonly DEFAULT_TIMEOUT = 30_000;
|
||||
private static readonly MAX_REDIRECTS = 5;
|
||||
private static readonly DEFAULT_HEADERS = {
|
||||
Accept: '*/*',
|
||||
'User-Agent': FLUXER_USER_AGENT,
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Pragma: 'no-cache',
|
||||
};
|
||||
|
||||
private static getHeadersForUrl(_url: string, customHeaders?: Record<string, string>): Record<string, string> {
|
||||
return {...HttpClient.DEFAULT_HEADERS, ...customHeaders};
|
||||
}
|
||||
|
||||
private static createCombinedController(...signals: Array<AbortSignal>): AbortController {
|
||||
const controller = new AbortController();
|
||||
for (const signal of signals) {
|
||||
if (signal.aborted) {
|
||||
controller.abort(signal.reason);
|
||||
break;
|
||||
}
|
||||
signal.addEventListener('abort', () => controller.abort(signal.reason), {once: true});
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static createTimeoutController(timeout: number): AbortController {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort('Request timed out'), timeout);
|
||||
controller.signal.addEventListener('abort', () => clearTimeout(timeoutId), {once: true});
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static normalizeHeaders(headers: Record<string, string | Array<string> | undefined>): Headers {
|
||||
const result = new Headers();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
result.append(key, entry);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (value) {
|
||||
result.set(key, value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async handleRedirect(
|
||||
statusCode: number,
|
||||
headers: Record<string, string | Array<string>>,
|
||||
currentUrl: string,
|
||||
options: RequestOptions,
|
||||
signal: AbortSignal,
|
||||
redirectCount = 0,
|
||||
): Promise<RedirectResult> {
|
||||
if (redirectCount >= HttpClient.MAX_REDIRECTS) {
|
||||
throw new HttpError(`Maximum number of redirects (${HttpClient.MAX_REDIRECTS}) exceeded`);
|
||||
}
|
||||
|
||||
if (![301, 302, 303, 307, 308].includes(statusCode)) {
|
||||
throw new HttpError(`Expected redirect status but got ${statusCode}`);
|
||||
}
|
||||
|
||||
const location = headers.location;
|
||||
if (!location) {
|
||||
throw new HttpError('Received redirect response without Location header', statusCode);
|
||||
}
|
||||
|
||||
const redirectUrl = new URL(Array.isArray(location) ? location[0] : location, currentUrl).toString();
|
||||
const requestHeaders = HttpClient.getHeadersForUrl(redirectUrl, options.headers);
|
||||
|
||||
const redirectMethod = statusCode === 303 ? 'GET' : (options.method ?? 'GET');
|
||||
const redirectBody = statusCode === 303 ? undefined : options.body;
|
||||
|
||||
const {
|
||||
statusCode: newStatusCode,
|
||||
headers: newHeaders,
|
||||
body,
|
||||
} = await request(redirectUrl, {
|
||||
method: redirectMethod,
|
||||
headers: requestHeaders,
|
||||
body: redirectBody ? JSON.stringify(redirectBody) : undefined,
|
||||
signal,
|
||||
});
|
||||
|
||||
if ([301, 302, 303, 307, 308].includes(newStatusCode)) {
|
||||
return HttpClient.handleRedirect(
|
||||
newStatusCode,
|
||||
newHeaders as Record<string, string | Array<string>>,
|
||||
redirectUrl,
|
||||
options,
|
||||
signal,
|
||||
redirectCount + 1,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
body,
|
||||
headers: newHeaders as Record<string, string | Array<string>>,
|
||||
statusCode: newStatusCode,
|
||||
finalUrl: redirectUrl,
|
||||
};
|
||||
}
|
||||
|
||||
public static async sendRequest(options: RequestOptions): Promise<StreamResponse> {
|
||||
const timeoutController = HttpClient.createTimeoutController(options.timeout ?? HttpClient.DEFAULT_TIMEOUT);
|
||||
const combinedController = options.signal
|
||||
? HttpClient.createCombinedController(options.signal, timeoutController.signal)
|
||||
: timeoutController;
|
||||
const headers = HttpClient.getHeadersForUrl(options.url, options.headers);
|
||||
|
||||
try {
|
||||
const {
|
||||
statusCode,
|
||||
headers: responseHeaders,
|
||||
body,
|
||||
} = await request(options.url, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: combinedController.signal,
|
||||
});
|
||||
|
||||
let finalBody = body;
|
||||
let finalHeaders: Record<string, string | Array<string> | undefined> = responseHeaders;
|
||||
let finalStatusCode = statusCode;
|
||||
let finalUrl = options.url;
|
||||
|
||||
if (statusCode === 304) {
|
||||
return {
|
||||
stream: body,
|
||||
headers: HttpClient.normalizeHeaders(responseHeaders),
|
||||
status: statusCode,
|
||||
url: options.url,
|
||||
};
|
||||
}
|
||||
|
||||
if ([301, 302, 303, 307, 308].includes(statusCode)) {
|
||||
const redirectResult = await HttpClient.handleRedirect(
|
||||
statusCode,
|
||||
responseHeaders as Record<string, string | Array<string>>,
|
||||
options.url,
|
||||
options,
|
||||
combinedController.signal,
|
||||
);
|
||||
finalBody = redirectResult.body;
|
||||
finalHeaders = redirectResult.headers;
|
||||
finalStatusCode = redirectResult.statusCode;
|
||||
finalUrl = redirectResult.finalUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
stream: finalBody,
|
||||
headers: HttpClient.normalizeHeaders(finalHeaders),
|
||||
status: finalStatusCode,
|
||||
url: finalUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof errors.RequestAbortedError) {
|
||||
metrics.counter({name: 'media_proxy.external.error', dimensions: {error_type: 'aborted'}});
|
||||
throw new HttpError('Request aborted', undefined, undefined, true, 'other');
|
||||
}
|
||||
if (error instanceof errors.BodyTimeoutError) {
|
||||
metrics.counter({name: 'media_proxy.external.error', dimensions: {error_type: 'body_timeout'}});
|
||||
throw new HttpError('Request timed out', undefined, undefined, true, 'timeout');
|
||||
}
|
||||
if (error instanceof errors.ConnectTimeoutError) {
|
||||
metrics.counter({name: 'media_proxy.external.error', dimensions: {error_type: 'connect_timeout'}});
|
||||
throw new HttpError('Connection timeout', undefined, undefined, true, 'timeout');
|
||||
}
|
||||
if (error instanceof errors.SocketError) {
|
||||
metrics.counter({name: 'media_proxy.external.error', dimensions: {error_type: 'socket_error'}});
|
||||
throw new HttpError(`Socket error: ${error.message}`, undefined, undefined, true, 'upstream_5xx');
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Request failed';
|
||||
const isNetworkError =
|
||||
error instanceof Error &&
|
||||
(error.message.includes('ENOTFOUND') ||
|
||||
error.message.includes('ECONNREFUSED') ||
|
||||
error.message.includes('ECONNRESET') ||
|
||||
error.message.includes('ETIMEDOUT') ||
|
||||
error.message.includes('EAI_AGAIN') ||
|
||||
error.message.includes('EHOSTUNREACH') ||
|
||||
error.message.includes('ENETUNREACH'));
|
||||
|
||||
if (isNetworkError) {
|
||||
metrics.counter({name: 'media_proxy.external.error', dimensions: {error_type: 'network'}});
|
||||
}
|
||||
|
||||
throw new HttpError(
|
||||
errorMessage,
|
||||
undefined,
|
||||
undefined,
|
||||
isNetworkError,
|
||||
isNetworkError ? 'upstream_5xx' : 'other',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static async streamToString(stream: ResponseStream): Promise<string> {
|
||||
const chunks: Array<Uint8Array> = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(new Uint8Array(Buffer.from(chunk)));
|
||||
}
|
||||
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
export const {sendRequest} = HttpClient;
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* 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 crypto from 'node:crypto';
|
||||
|
||||
const BASE64_URL_REGEX = /=*$/;
|
||||
|
||||
const decodeComponent = (component: string) => decodeURIComponent(component);
|
||||
|
||||
const createSignature = (inputString: string, mediaProxySecretKey: string): string => {
|
||||
const hmac = crypto.createHmac('sha256', mediaProxySecretKey);
|
||||
hmac.update(inputString);
|
||||
return hmac.digest('base64url').replace(BASE64_URL_REGEX, '');
|
||||
};
|
||||
|
||||
export const verifySignature = (
|
||||
proxyUrlPath: string,
|
||||
providedSignature: string,
|
||||
mediaProxySecretKey: string,
|
||||
): boolean => {
|
||||
const expectedSignature = createSignature(proxyUrlPath, mediaProxySecretKey);
|
||||
return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(providedSignature));
|
||||
};
|
||||
|
||||
export const reconstructOriginalURL = (proxyUrlPath: string): string => {
|
||||
const parts = proxyUrlPath.split('/');
|
||||
let currentIndex = 0;
|
||||
let query = '';
|
||||
if (parts[currentIndex].includes('%3D')) {
|
||||
query = decodeComponent(parts[currentIndex]);
|
||||
currentIndex += 1;
|
||||
}
|
||||
const protocol = parts[currentIndex++];
|
||||
if (!protocol) throw new Error('Protocol is missing in the proxy URL path.');
|
||||
const hostPart = parts[currentIndex++];
|
||||
if (!hostPart) throw new Error('Hostname is missing in the proxy URL path.');
|
||||
const [encodedHostname, encodedPort] = hostPart.split(':');
|
||||
const hostname = decodeComponent(encodedHostname);
|
||||
const port = encodedPort ? decodeComponent(encodedPort) : '';
|
||||
const encodedPath = parts.slice(currentIndex).join('/');
|
||||
const path = decodeComponent(encodedPath);
|
||||
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${query ? `?${query}` : ''}`;
|
||||
};
|
||||
Reference in New Issue
Block a user