initial commit
This commit is contained in:
42
fluxer_media_proxy/src/lib/BinaryUtils.ts
Normal file
42
fluxer_media_proxy/src/lib/BinaryUtils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Readable, type Stream} from 'node:stream';
|
||||
import type {ReadableStream as WebReadableStream} from 'node:stream/web';
|
||||
|
||||
type BinaryLike = ArrayBufferView | ArrayBuffer;
|
||||
|
||||
export 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);
|
||||
};
|
||||
274
fluxer_media_proxy/src/lib/CloudflareIPService.ts
Normal file
274
fluxer_media_proxy/src/lib/CloudflareIPService.ts
Normal 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;
|
||||
}
|
||||
96
fluxer_media_proxy/src/lib/CodecValidation.ts
Normal file
96
fluxer_media_proxy/src/lib/CodecValidation.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
}
|
||||
};
|
||||
76
fluxer_media_proxy/src/lib/FFmpegUtils.ts
Normal file
76
fluxer_media_proxy/src/lib/FFmpegUtils.ts
Normal 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;
|
||||
};
|
||||
67
fluxer_media_proxy/src/lib/HttpUtils.ts
Normal file
67
fluxer_media_proxy/src/lib/HttpUtils.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Context} from 'hono';
|
||||
|
||||
export 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());
|
||||
}
|
||||
};
|
||||
63
fluxer_media_proxy/src/lib/ImageProcessing.ts
Normal file
63
fluxer_media_proxy/src/lib/ImageProcessing.ts
Normal 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();
|
||||
};
|
||||
45
fluxer_media_proxy/src/lib/InMemoryCoalescer.ts
Normal file
45
fluxer_media_proxy/src/lib/InMemoryCoalescer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
113
fluxer_media_proxy/src/lib/MediaTypes.ts
Normal file
113
fluxer_media_proxy/src/lib/MediaTypes.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
141
fluxer_media_proxy/src/lib/MediaValidation.ts
Normal file
141
fluxer_media_proxy/src/lib/MediaValidation.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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'});
|
||||
};
|
||||
89
fluxer_media_proxy/src/lib/MetricsClient.ts
Normal file
89
fluxer_media_proxy/src/lib/MetricsClient.ts
Normal 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;
|
||||
}
|
||||
63
fluxer_media_proxy/src/lib/MimeTypeUtils.ts
Normal file
63
fluxer_media_proxy/src/lib/MimeTypeUtils.ts
Normal 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;
|
||||
};
|
||||
108
fluxer_media_proxy/src/lib/NSFWDetectionService.ts
Normal file
108
fluxer_media_proxy/src/lib/NSFWDetectionService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
203
fluxer_media_proxy/src/lib/S3Utils.ts
Normal file
203
fluxer_media_proxy/src/lib/S3Utils.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user