refactor progress

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

View File

@@ -1,34 +1,52 @@
ARG BUILD_SHA
ARG BUILD_NUMBER
ARG BUILD_TIMESTAMP
ARG RELEASE_CHANNEL=nightly
FROM node:24-bookworm-slim AS base
WORKDIR /usr/src/app
RUN corepack enable && corepack prepare pnpm@10.26.0 --activate
FROM base AS generator
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches/ ./patches/
COPY packages/config/ ./packages/config/
COPY packages/constants/ ./packages/constants/
RUN pnpm install --frozen-lockfile --filter @fluxer/config...
RUN pnpm --filter @fluxer/config generate
FROM base AS deps
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
gcc \
libssl-dev \
pkg-config \
ffmpeg \
libvips-dev \
libgomp1 \
libatomic1 \
python3 \
make && \
build-essential \
gcc \
g++ \
make \
python3 \
pkg-config \
libssl-dev \
libvips-dev && \
rm -rf /var/lib/apt/lists/*
COPY package.json pnpm-lock.yaml ./
RUN pnpm fetch --prod && pnpm install --frozen-lockfile --prod --offline
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches/ ./patches/
COPY packages/ ./packages/
COPY fluxer_media_proxy/package.json ./fluxer_media_proxy/
COPY tsconfig.json ./
COPY src ./src
COPY data ./data
RUN pnpm install --frozen-lockfile --prod
FROM node:24-bookworm-slim AS build
FROM node:24-bookworm-slim
WORKDIR /usr/src/app
ARG BUILD_SHA
ARG BUILD_NUMBER
ARG BUILD_TIMESTAMP
ARG RELEASE_CHANNEL
WORKDIR /usr/src/app/fluxer_media_proxy
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
@@ -40,12 +58,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN corepack enable && corepack prepare pnpm@10.26.0 --activate
COPY --from=deps /usr/src/app/node_modules ./node_modules
COPY --from=deps /usr/src/app/package.json .
COPY --from=deps /usr/src/app/pnpm-lock.yaml .
COPY --from=deps /usr/src/app/tsconfig.json .
COPY --from=deps /usr/src/app/src ./src
COPY --from=deps /usr/src/app/data ./data
COPY --from=deps /usr/src/app/node_modules /usr/src/app/node_modules
COPY --from=deps /usr/src/app/fluxer_media_proxy/node_modules ./node_modules
COPY --from=deps /usr/src/app/packages /usr/src/app/packages
COPY --from=generator /usr/src/app/packages/config/src/ConfigSchema.json /usr/src/app/packages/config/src/ConfigSchema.json
COPY --from=generator /usr/src/app/packages/config/src/MasterZodSchema.generated.tsx /usr/src/app/packages/config/src/MasterZodSchema.generated.tsx
COPY tsconfigs /usr/src/app/tsconfigs
COPY fluxer_media_proxy/package.json ./
COPY fluxer_media_proxy/tsconfig.json ./
COPY fluxer_media_proxy/src ./src
COPY fluxer_media_proxy/data ./data
RUN mkdir -p /opt/data /usr/src/app/.cache/corepack && \
cp data/model.onnx /opt/data/model.onnx && \
@@ -53,12 +75,15 @@ RUN mkdir -p /opt/data /usr/src/app/.cache/corepack && \
ENV HOME=/usr/src/app
ENV COREPACK_HOME=/usr/src/app/.cache/corepack
ENV NODE_ENV=production
ENV PORT=8080
ENV BUILD_SHA=${BUILD_SHA}
ENV BUILD_NUMBER=${BUILD_NUMBER}
ENV BUILD_TIMESTAMP=${BUILD_TIMESTAMP}
ENV RELEASE_CHANNEL=${RELEASE_CHANNEL}
USER nobody
EXPOSE 8080
ENV NODE_ENV=production
ENV PORT=8080
CMD ["pnpm", "start"]

View File

@@ -2,38 +2,25 @@
"name": "fluxer_media_proxy",
"private": true,
"type": "module",
"packageManager": "pnpm@10.26.0",
"scripts": {
"dev": "tsx watch --clear-screen=false src/App.ts",
"start": "tsx src/App.ts"
"dev": "tsx watch --clear-screen=false src/App.tsx",
"start": "tsx src/App.tsx",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "3.955.0",
"@hono/node-server": "1.19.7",
"@sentry/node": "10.32.0",
"@tsconfig/node22": "22.0.5",
"@tsconfig/strictest": "2.0.8",
"hono": "4.11.1",
"magic-bytes.js": "1.12.1",
"onnxruntime-node": "1.23.2",
"pino": "10.1.0",
"pino-pretty": "13.1.3",
"sharp": "0.34.5",
"tempy": "3.1.0",
"thumbhash": "0.1.1",
"tsx": "4.21.0",
"undici": "7.16.0",
"valibot": "1.2.0"
"@fluxer/config": "workspace:*",
"@fluxer/hono": "workspace:*",
"@fluxer/initialization": "workspace:*",
"@fluxer/logger": "workspace:*",
"@fluxer/media_proxy": "workspace:*",
"@fluxer/rate_limit": "workspace:*",
"@fluxer/telemetry": "workspace:*",
"@opentelemetry/api": "catalog:",
"tsx": "catalog:"
},
"devDependencies": {
"@types/node": "25.0.3",
"typescript": "5.9.3"
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"onnxruntime-node",
"sharp"
]
}
"packageManager": "pnpm@10.29.3"
}

File diff suppressed because it is too large Load Diff

View File

@@ -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}`);

View File

@@ -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';

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

View File

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

View 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;

View File

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

View File

@@ -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;

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {
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();
},
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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;
}
};

View File

@@ -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;
};

View File

@@ -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());
}
};

View File

@@ -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();
};

View File

@@ -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;
};
}

View File

@@ -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'});
};

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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;
}
}

View File

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

View File

@@ -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();
});

View File

@@ -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();
});

View File

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

View File

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

View File

@@ -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;

View File

@@ -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}` : ''}`;
};

View File

@@ -1,17 +1,10 @@
{
"extends": "../tsconfigs/service.json",
"compilerOptions": {
"exactOptionalPropertyTypes": false,
"lib": ["ESNext"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": false,
"paths": {
"~/*": ["./src/*"]
},
"target": "ESNext"
"@app/*": ["./src/*"],
"@fluxer/*": ["./../packages/*", "./../packages/*/src/index.tsx"]
}
},
"extends": ["@tsconfig/strictest/tsconfig", "@tsconfig/node22/tsconfig"],
"include": ["src/**/*"]
}