/*
* 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 .
*/
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) => {
const request = await ctx.req.json();
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 = {};
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,
});
});
};
};