mirror of
https://github.com/misskey-dev/media-proxy.git
synced 2025-04-29 02:47:26 +09:00
231 lines
8.7 KiB
JavaScript
231 lines
8.7 KiB
JavaScript
import * as fs from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname } from 'node:path';
|
|
import fastifyStatic from '@fastify/static';
|
|
import { createTemp } from './create-temp.js';
|
|
import { FILE_TYPE_BROWSERSAFE } from './const.js';
|
|
import { convertToWebpStream, webpDefault, convertSharpToWebpStream } from './image-processor.js';
|
|
import { detectType, isMimeImage } from './file-info.js';
|
|
import sharp from 'sharp';
|
|
import { sharpBmp } from 'sharp-read-bmp';
|
|
import { StatusError } from './status-error.js';
|
|
import { defaultDownloadConfig, downloadUrl } from './download.js';
|
|
import { getAgents } from './http.js';
|
|
import _contentDisposition from 'content-disposition';
|
|
const _filename = fileURLToPath(import.meta.url);
|
|
const _dirname = dirname(_filename);
|
|
const assets = `${_dirname}/../assets/`;
|
|
let config = defaultDownloadConfig;
|
|
export function setMediaProxyConfig(setting) {
|
|
const proxy = process.env.HTTP_PROXY ?? process.env.http_proxy;
|
|
if (!setting) {
|
|
config = {
|
|
...defaultDownloadConfig,
|
|
...(proxy ? getAgents(proxy) : {}),
|
|
proxy: !!proxy,
|
|
};
|
|
console.log(config);
|
|
return;
|
|
}
|
|
config = {
|
|
userAgent: setting.userAgent ?? defaultDownloadConfig.userAgent,
|
|
allowedPrivateNetworks: setting.allowedPrivateNetworks ?? defaultDownloadConfig.allowedPrivateNetworks,
|
|
maxSize: setting.maxSize ?? defaultDownloadConfig.maxSize,
|
|
...('proxy' in setting ?
|
|
{ ...getAgents(setting.proxy), proxy: !!setting.proxy } :
|
|
'httpAgent' in setting ? {
|
|
httpAgent: setting.httpAgent,
|
|
httpsAgent: setting.httpsAgent,
|
|
proxy: true,
|
|
} :
|
|
{ ...getAgents(proxy), proxy: !!proxy }),
|
|
};
|
|
console.log(config);
|
|
}
|
|
export default function (fastify, options, done) {
|
|
setMediaProxyConfig(options);
|
|
const corsOrigin = options['Access-Control-Allow-Origin'] ?? '*';
|
|
const corsHeader = options['Access-Control-Allow-Headers'] ?? '*';
|
|
const csp = options['Content-Security-Policy'] ?? `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`;
|
|
fastify.addHook('onRequest', (request, reply, done) => {
|
|
reply.header('Access-Control-Allow-Origin', corsOrigin);
|
|
reply.header('Access-Control-Allow-Headers', corsHeader);
|
|
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
reply.header('Content-Security-Policy', csp);
|
|
done();
|
|
});
|
|
fastify.register(fastifyStatic, {
|
|
root: _dirname,
|
|
serve: false,
|
|
});
|
|
fastify.get('/:url*', async (request, reply) => {
|
|
return await proxyHandler(request, reply)
|
|
.catch(err => errorHandler(request, reply, err));
|
|
});
|
|
done();
|
|
}
|
|
function errorHandler(request, reply, err) {
|
|
console.log(`${err}`);
|
|
reply.header('Cache-Control', 'max-age=300');
|
|
if (request.query && 'fallback' in request.query) {
|
|
return reply.sendFile('/dummy.png', assets);
|
|
}
|
|
if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) {
|
|
reply.code(err.statusCode);
|
|
return;
|
|
}
|
|
reply.code(500);
|
|
return;
|
|
}
|
|
async function proxyHandler(request, reply) {
|
|
const url = 'url' in request.query ? request.query.url : (request.params.url && 'https://' + request.params.url);
|
|
if (!url || typeof url !== 'string') {
|
|
reply.code(400);
|
|
return;
|
|
}
|
|
// Create temp file
|
|
const file = await downloadAndDetectTypeFromUrl(url);
|
|
try {
|
|
const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image');
|
|
const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image');
|
|
if ('emoji' in request.query ||
|
|
'avatar' in request.query ||
|
|
'static' in request.query ||
|
|
'preview' in request.query ||
|
|
'badge' in request.query) {
|
|
if (!isConvertibleImage) {
|
|
// 画像でないなら404でお茶を濁す
|
|
throw new StatusError('Unexpected mime', 404);
|
|
}
|
|
}
|
|
let image = null;
|
|
if ('emoji' in request.query || 'avatar' in request.query) {
|
|
if (!isAnimationConvertibleImage && !('static' in request.query)) {
|
|
image = {
|
|
data: fs.createReadStream(file.path),
|
|
ext: file.ext,
|
|
type: file.mime,
|
|
};
|
|
}
|
|
else {
|
|
const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
|
|
.resize({
|
|
height: 'emoji' in request.query ? 128 : 320,
|
|
withoutEnlargement: true,
|
|
})
|
|
.webp(webpDefault);
|
|
image = {
|
|
data,
|
|
ext: 'webp',
|
|
type: 'image/webp',
|
|
};
|
|
}
|
|
}
|
|
else if ('static' in request.query) {
|
|
image = convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422);
|
|
}
|
|
else if ('preview' in request.query) {
|
|
image = convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
|
|
}
|
|
else if ('badge' in request.query) {
|
|
const mask = (await sharpBmp(file.path, file.mime))
|
|
.resize(96, 96, {
|
|
fit: 'contain',
|
|
position: 'centre',
|
|
withoutEnlargement: false,
|
|
})
|
|
.greyscale()
|
|
.normalise()
|
|
.linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast
|
|
.flatten({ background: '#000' })
|
|
.toColorspace('b-w');
|
|
const stats = await mask.clone().stats();
|
|
if (stats.entropy < 0.1) {
|
|
// エントロピーがあまりない場合は404にする
|
|
throw new StatusError('Skip to provide badge', 404);
|
|
}
|
|
const data = sharp({
|
|
create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
|
})
|
|
.pipelineColorspace('b-w')
|
|
.boolean(await mask.png().toBuffer(), 'eor');
|
|
image = {
|
|
data: await data.png().toBuffer(),
|
|
ext: 'png',
|
|
type: 'image/png',
|
|
};
|
|
}
|
|
else if (file.mime === 'image/svg+xml') {
|
|
image = convertToWebpStream(file.path, 2048, 2048);
|
|
}
|
|
else if (!(file.mime.startsWith('image/') || FILE_TYPE_BROWSERSAFE.includes(file.mime))) {
|
|
throw new StatusError('Rejected type', 403, 'Rejected type');
|
|
}
|
|
if (!image) {
|
|
image = {
|
|
data: fs.createReadStream(file.path),
|
|
ext: file.ext,
|
|
type: file.mime,
|
|
};
|
|
}
|
|
if ('cleanup' in file) {
|
|
if ('pipe' in image.data && typeof image.data.pipe === 'function') {
|
|
// image.dataがstreamなら、stream終了後にcleanup
|
|
const cleanup = () => {
|
|
file.cleanup();
|
|
image = null;
|
|
};
|
|
image.data.on('end', cleanup);
|
|
image.data.on('close', cleanup);
|
|
}
|
|
else {
|
|
// image.dataがstreamでないなら直ちにcleanup
|
|
file.cleanup();
|
|
}
|
|
}
|
|
reply.header('Content-Type', image.type);
|
|
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
|
reply.header('Content-Disposition', contentDisposition('inline', correctFilename(file.filename, image.ext)));
|
|
return reply.send(image.data);
|
|
}
|
|
catch (e) {
|
|
if ('cleanup' in file)
|
|
file.cleanup();
|
|
throw e;
|
|
}
|
|
}
|
|
async function downloadAndDetectTypeFromUrl(url) {
|
|
const [path, cleanup] = await createTemp();
|
|
try {
|
|
const { filename } = await downloadUrl(url, path, config);
|
|
const { mime, ext } = await detectType(path);
|
|
return {
|
|
state: 'remote',
|
|
mime, ext,
|
|
path, cleanup,
|
|
filename: correctFilename(filename, ext),
|
|
};
|
|
}
|
|
catch (e) {
|
|
cleanup();
|
|
throw e;
|
|
}
|
|
}
|
|
function correctFilename(filename, ext) {
|
|
const dotExt = ext ? `.${ext}` : '.unknown';
|
|
if (filename.endsWith(dotExt)) {
|
|
return filename;
|
|
}
|
|
if (ext === 'jpg' && filename.endsWith('.jpeg')) {
|
|
return filename;
|
|
}
|
|
if (ext === 'tif' && filename.endsWith('.tiff')) {
|
|
return filename;
|
|
}
|
|
return `${filename}${dotExt}`;
|
|
}
|
|
function contentDisposition(type, filename) {
|
|
const fallback = filename.replace(/[^\w.-]/g, '_');
|
|
return _contentDisposition(filename, { type, fallback });
|
|
}
|