initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

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

@@ -0,0 +1,274 @@
/*
* 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

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

@@ -0,0 +1,76 @@
/*
* 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

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

@@ -0,0 +1,63 @@
/*
* 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

@@ -0,0 +1,45 @@
/*
* 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 metrics from '~/lib/MetricsClient';
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;
}
}

View File

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

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

@@ -0,0 +1,89 @@
/*
* 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

@@ -0,0 +1,63 @@
/*
* 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

@@ -0,0 +1,108 @@
/*
* 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

@@ -0,0 +1,203 @@
/*
* 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);
};