11 Commits

Author SHA1 Message Date
384c51569f update SPECIFICATION.md 2023-03-17 15:51:04 +00:00
57aa87f370 0.0.19 2023-03-17 15:50:07 +00:00
d86823e32d 0.0.18 2023-03-17 13:44:13 +00:00
405e6a5adb quality, effort変更 2023-03-17 13:43:45 +00:00
78cf2469d4 0.0.17 2023-03-03 16:43:52 +00:00
969d27e1b3 0.0.16 2023-02-28 17:12:01 +00:00
d5f5f4023c 0.0.15 2023-02-28 16:06:23 +00:00
30c8088b8e modify SPECIFICATION.md 2023-02-28 15:26:49 +00:00
4d3869020a 0.0.14 2023-02-28 15:23:21 +00:00
4e2230d7cf fix build 2023-02-28 15:22:36 +00:00
cfaf017c15 Content-Dispositionでダウンロード時の名前を指定
Fix https://github.com/misskey-dev/media-proxy/issues/6
2023-02-28 15:22:00 +00:00
13 changed files with 165 additions and 61 deletions

View File

@ -1,32 +0,0 @@
module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
extends: [
'../shared/.eslintrc.js',
],
rules: {
'import/order': ['warn', {
'groups': ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
'pathGroups': [
{
'pattern': '@/**',
'group': 'external',
'position': 'after'
}
],
}],
'no-restricted-globals': [
'error',
{
'name': '__dirname',
'message': 'Not in ESModule. Use `import.meta.url` instead.'
},
{
'name': '__filename',
'message': 'Not in ESModule. Use `import.meta.url` instead.'
}
]
},
};

View File

@ -26,6 +26,7 @@ Acceptヘッダーは無視される。
Cache-Controlは、正常なレスポンスの場合`max-age=31536000, immutable`、エラーレスポンスの場合`max-age=300`である。 Cache-Controlは、正常なレスポンスの場合`max-age=31536000, immutable`、エラーレスポンスの場合`max-age=300`である。
Content-Typeは、ファイルの内容について適切なものが挿入される。 Content-Typeは、ファイルの内容について適切なものが挿入される。
Content-Security-Policyは、`default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`となっている。 Content-Security-Policyは、`default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`となっている。
Content-Dispositionは、filenameは元画像のContent-Disposition.filenameもしくはファイル名に基づいて挿入される。拡張子は適宜変更され、octet-streamの場合は拡張子として.unknownが付加される。inlineが指定される。
### クエリの一覧 ### クエリの一覧
#### url (必須) #### url (必須)
@ -51,6 +52,9 @@ https://www.google.com/images/errors/robot.png をプロキシする場合:
変換形式が指定されていなかった場合は、画像ファイルもしくは許可されたファイルFILE_TYPE_BROWSERSAFEである場合のみプロキシファイルの再配信が行われる。 変換形式が指定されていなかった場合は、画像ファイルもしくは許可されたファイルFILE_TYPE_BROWSERSAFEである場合のみプロキシファイルの再配信が行われる。
ただし、svgは、webpに変換される最大サイズ2048x2048 ただし、svgは、webpに変換される最大サイズ2048x2048
#### 変換クエリ付加時の挙動
一方、以下の変換クエリが指定されているが、元ファイルがsharp.jsで変換できない形式の場合、404が返される。
#### emoji #### emoji
存在すると、高さ128px以下のwebpが応答される。 存在すると、高さ128px以下のwebpが応答される。
ただし、sharp.jsの都合により、元画像がapngの場合は無変換で応答される。 ただし、sharp.jsの都合により、元画像がapngの場合は無変換で応答される。
@ -68,7 +72,7 @@ https://www.google.com/images/errors/robot.png をプロキシする場合:
#### static #### static
存在すると、アニメーション画像では最初のフレームのみの静止画のwebpが応答される。 存在すると、アニメーション画像では最初のフレームのみの静止画のwebpが応答される。
emojiまたはavatarとstaticが同時に指定された場合は、それぞれに応じた高さが、指定されていない場合は幅498px・高さ280pxに収まるサイズ以下に縮小される。 emojiまたはavatarとstaticが同時に指定された場合は、それぞれに応じた高さが、指定されていない場合は幅498px・高さ422pxに収まるサイズ以下に縮小される。
#### preview #### preview
存在すると、幅200px・高さ200pxに収まるサイズ以下のwebpが応答される。 存在すると、幅200px・高さ200pxに収まるサイズ以下のwebpが応答される。

4
built/download.d.ts vendored
View File

@ -19,4 +19,6 @@ export declare const defaultDownloadConfig: {
maxSize: number; maxSize: number;
proxy: boolean; proxy: boolean;
}; };
export declare function downloadUrl(url: string, path: string, settings?: DownloadConfig): Promise<void>; export declare function downloadUrl(url: string, path: string, settings?: DownloadConfig): Promise<{
filename: string;
}>;

View File

@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr';
import PrivateIp from 'private-ip'; import PrivateIp from 'private-ip';
import { StatusError } from './status-error.js'; import { StatusError } from './status-error.js';
import { getAgents } from './http.js'; import { getAgents } from './http.js';
import { parse } from 'content-disposition';
const pipeline = util.promisify(stream.pipeline); const pipeline = util.promisify(stream.pipeline);
export const defaultDownloadConfig = { export const defaultDownloadConfig = {
userAgent: `MisskeyMediaProxy/0.0.0`, userAgent: `MisskeyMediaProxy/0.0.0`,
@ -19,6 +20,8 @@ export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
console.log(`Downloading ${url} to ${path} ...`); console.log(`Downloading ${url} to ${path} ...`);
const timeout = 30 * 1000; const timeout = 30 * 1000;
const operationTimeout = 60 * 1000; const operationTimeout = 60 * 1000;
const urlObj = new URL(url);
let filename = urlObj.pathname.split('/').pop() ?? 'unknown';
const req = got.stream(url, { const req = got.stream(url, {
headers: { headers: {
'User-Agent': settings.userAgent, 'User-Agent': settings.userAgent,
@ -56,6 +59,13 @@ export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
req.destroy(); req.destroy();
} }
} }
const contentDisposition = res.headers['content-disposition'];
if (contentDisposition != null) {
const parsed = parse(contentDisposition);
if (parsed.parameters.filename) {
filename = parsed.parameters.filename;
}
}
}).on('downloadProgress', (progress) => { }).on('downloadProgress', (progress) => {
if (progress.transferred > settings.maxSize) { if (progress.transferred > settings.maxSize) {
console.log(`maxSize exceeded (${progress.transferred} > ${settings.maxSize}) on downloadProgress`); console.log(`maxSize exceeded (${progress.transferred} > ${settings.maxSize}) on downloadProgress`);
@ -75,6 +85,9 @@ export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
} }
if (process.env.NODE_ENV !== 'production') if (process.env.NODE_ENV !== 'production')
console.log(`Download finished: ${url}`); console.log(`Download finished: ${url}`);
return {
filename,
};
} }
function isPrivateIp(ip, allowedPrivateNetworks) { function isPrivateIp(ip, allowedPrivateNetworks) {
for (const net of allowedPrivateNetworks ?? []) { for (const net of allowedPrivateNetworks ?? []) {

View File

@ -55,7 +55,7 @@ async function checkSvg(path) {
import { FILE_TYPE_BROWSERSAFE } from './const.js'; import { FILE_TYPE_BROWSERSAFE } from './const.js';
const dictionary = { const dictionary = {
'safe-file': FILE_TYPE_BROWSERSAFE, 'safe-file': FILE_TYPE_BROWSERSAFE,
'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'], 'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'], 'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
}; };
export const isMimeImage = (mime, type) => dictionary[type].includes(mime); export const isMimeImage = (mime, type) => dictionary[type].includes(mime);

View File

@ -1,11 +1,12 @@
import sharp from 'sharp'; import sharp from 'sharp';
export const webpDefault = { export const webpDefault = {
quality: 85, quality: 77,
alphaQuality: 95, alphaQuality: 95,
lossless: false, lossless: false,
nearLossless: false, nearLossless: false,
smartSubsample: true, smartSubsample: true,
mixed: true, mixed: true,
effort: 2,
}; };
export function convertToWebpStream(path, width, height, options = webpDefault) { export function convertToWebpStream(path, width, height, options = webpDefault) {
return convertSharpToWebpStream(sharp(path), width, height, options); return convertSharpToWebpStream(sharp(path), width, height, options);

View File

@ -4,12 +4,14 @@ import { dirname } from 'node:path';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import { createTemp } from './create-temp.js'; import { createTemp } from './create-temp.js';
import { FILE_TYPE_BROWSERSAFE } from './const.js'; import { FILE_TYPE_BROWSERSAFE } from './const.js';
import { convertToWebpStream, webpDefault } from './image-processor.js'; import { convertToWebpStream, webpDefault, convertSharpToWebpStream } from './image-processor.js';
import { detectType, isMimeImage } from './file-info.js'; import { detectType, isMimeImage } from './file-info.js';
import sharp from 'sharp'; import sharp from 'sharp';
import { sharpBmp } from 'sharp-read-bmp';
import { StatusError } from './status-error.js'; import { StatusError } from './status-error.js';
import { defaultDownloadConfig, downloadUrl } from './download.js'; import { defaultDownloadConfig, downloadUrl } from './download.js';
import { getAgents } from './http.js'; import { getAgents } from './http.js';
import _contentDisposition from 'content-disposition';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
const assets = `${_dirname}/../assets/`; const assets = `${_dirname}/../assets/`;
@ -106,7 +108,7 @@ async function proxyHandler(request, reply) {
}; };
} }
else { else {
const data = sharp(file.path, { animated: !('static' in request.query) }) const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
.resize({ .resize({
height: 'emoji' in request.query ? 128 : 320, height: 'emoji' in request.query ? 128 : 320,
withoutEnlargement: true, withoutEnlargement: true,
@ -120,13 +122,13 @@ async function proxyHandler(request, reply) {
} }
} }
else if ('static' in request.query) { else if ('static' in request.query) {
image = convertToWebpStream(file.path, 498, 280); image = convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422);
} }
else if ('preview' in request.query) { else if ('preview' in request.query) {
image = convertToWebpStream(file.path, 200, 200); image = convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
} }
else if ('badge' in request.query) { else if ('badge' in request.query) {
const mask = sharp(file.path) const mask = (await sharpBmp(file.path, file.mime))
.resize(96, 96, { .resize(96, 96, {
fit: 'inside', fit: 'inside',
withoutEnlargement: false, withoutEnlargement: false,
@ -182,6 +184,7 @@ async function proxyHandler(request, reply) {
} }
reply.header('Content-Type', image.type); reply.header('Content-Type', image.type);
reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition', contentDisposition('inline', correctFilename(file.filename, image.ext)));
return reply.send(image.data); return reply.send(image.data);
} }
catch (e) { catch (e) {
@ -193,12 +196,13 @@ async function proxyHandler(request, reply) {
async function downloadAndDetectTypeFromUrl(url) { async function downloadAndDetectTypeFromUrl(url) {
const [path, cleanup] = await createTemp(); const [path, cleanup] = await createTemp();
try { try {
await downloadUrl(url, path, config); const { filename } = await downloadUrl(url, path, config);
const { mime, ext } = await detectType(path); const { mime, ext } = await detectType(path);
return { return {
state: 'remote', state: 'remote',
mime, ext, mime, ext,
path, cleanup, path, cleanup,
filename: correctFilename(filename, ext),
}; };
} }
catch (e) { catch (e) {
@ -206,3 +210,20 @@ async function downloadAndDetectTypeFromUrl(url) {
throw e; 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 });
}

View File

@ -1,9 +1,9 @@
{ {
"name": "misskey-media-proxy", "name": "misskey-media-proxy",
"version": "0.0.13", "version": "0.0.19",
"description": "The Media Proxy for Misskey", "description": "The Media Proxy for Misskey",
"main": "built/index.js", "main": "built/index.js",
"packageManager": "pnpm@7.26.0", "packageManager": "pnpm@7.28.0",
"type": "module", "type": "module",
"files": [ "files": [
"built", "built",
@ -28,6 +28,7 @@
"devDependencies": { "devDependencies": {
"@swc/cli": "^0.1.61", "@swc/cli": "^0.1.61",
"@swc/core": "^1.3.32", "@swc/core": "^1.3.32",
"@types/content-disposition": "^0.5.5",
"@types/node": "^18.11.19", "@types/node": "^18.11.19",
"@types/sharp": "^0.31.1", "@types/sharp": "^0.31.1",
"@types/tmp": "^0.2.3", "@types/tmp": "^0.2.3",
@ -36,6 +37,7 @@
"dependencies": { "dependencies": {
"@fastify/static": "^6.8.0", "@fastify/static": "^6.8.0",
"cacheable-lookup": "^7.0.0", "cacheable-lookup": "^7.0.0",
"content-disposition": "^0.5.4",
"fastify": "^4.12.0", "fastify": "^4.12.0",
"fastify-cli": "^5.7.1", "fastify-cli": "^5.7.1",
"file-type": "^18.2.0", "file-type": "^18.2.0",
@ -45,6 +47,7 @@
"is-svg": "^4.3.2", "is-svg": "^4.3.2",
"private-ip": "^3.0.0", "private-ip": "^3.0.0",
"sharp": "^0.31.3", "sharp": "^0.31.3",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"tmp": "^0.2.1" "tmp": "^0.2.1"
} }
} }

45
pnpm-lock.yaml generated
View File

@ -4,10 +4,12 @@ specifiers:
'@fastify/static': ^6.8.0 '@fastify/static': ^6.8.0
'@swc/cli': ^0.1.61 '@swc/cli': ^0.1.61
'@swc/core': ^1.3.32 '@swc/core': ^1.3.32
'@types/content-disposition': ^0.5.5
'@types/node': ^18.11.19 '@types/node': ^18.11.19
'@types/sharp': ^0.31.1 '@types/sharp': ^0.31.1
'@types/tmp': ^0.2.3 '@types/tmp': ^0.2.3
cacheable-lookup: ^7.0.0 cacheable-lookup: ^7.0.0
content-disposition: ^0.5.4
fastify: ^4.12.0 fastify: ^4.12.0
fastify-cli: ^5.7.1 fastify-cli: ^5.7.1
file-type: ^18.2.0 file-type: ^18.2.0
@ -17,12 +19,14 @@ specifiers:
is-svg: ^4.3.2 is-svg: ^4.3.2
private-ip: ^3.0.0 private-ip: ^3.0.0
sharp: ^0.31.3 sharp: ^0.31.3
sharp-read-bmp: github:misskey-dev/sharp-read-bmp
tmp: ^0.2.1 tmp: ^0.2.1
typescript: ^4.9.5 typescript: ^4.9.5
dependencies: dependencies:
'@fastify/static': 6.8.0 '@fastify/static': 6.8.0
cacheable-lookup: 7.0.0 cacheable-lookup: 7.0.0
content-disposition: 0.5.4
fastify: 4.12.0 fastify: 4.12.0
fastify-cli: 5.7.1 fastify-cli: 5.7.1
file-type: 18.2.0 file-type: 18.2.0
@ -32,11 +36,13 @@ dependencies:
is-svg: 4.3.2 is-svg: 4.3.2
private-ip: 3.0.0 private-ip: 3.0.0
sharp: 0.31.3 sharp: 0.31.3
sharp-read-bmp: github.com/misskey-dev/sharp-read-bmp/c5fb82e26fa50fca8b3b2d7e22467626d7f61baa
tmp: 0.2.1 tmp: 0.2.1
devDependencies: devDependencies:
'@swc/cli': 0.1.61_@swc+core@1.3.32 '@swc/cli': 0.1.61_@swc+core@1.3.32
'@swc/core': 1.3.32 '@swc/core': 1.3.32
'@types/content-disposition': 0.5.5
'@types/node': 18.11.19 '@types/node': 18.11.19
'@types/sharp': 0.31.1 '@types/sharp': 0.31.1
'@types/tmp': 0.2.3 '@types/tmp': 0.2.3
@ -44,6 +50,10 @@ devDependencies:
packages: packages:
/@canvas/image-data/1.0.0:
resolution: {integrity: sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==}
dev: false
/@chainsafe/is-ip/2.0.1: /@chainsafe/is-ip/2.0.1:
resolution: {integrity: sha512-nqSJ8u2a1Rv9FYbyI8qpDhTYujaKEyLknNrTejLYoSWmdeg+2WB7R6BZqPZYfrJzDxVi3rl6ZQuoaEvpKRZWgQ==} resolution: {integrity: sha512-nqSJ8u2a1Rv9FYbyI8qpDhTYujaKEyLknNrTejLYoSWmdeg+2WB7R6BZqPZYfrJzDxVi3rl6ZQuoaEvpKRZWgQ==}
dev: false dev: false
@ -300,6 +310,10 @@ packages:
'@types/responselike': 1.0.0 '@types/responselike': 1.0.0
dev: true dev: true
/@types/content-disposition/0.5.5:
resolution: {integrity: sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==}
dev: true
/@types/http-cache-semantics/4.0.1: /@types/http-cache-semantics/4.0.1:
resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==} resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==}
@ -635,6 +649,23 @@ packages:
ms: 2.1.2 ms: 2.1.2
dev: false dev: false
/decode-bmp/0.2.1:
resolution: {integrity: sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==}
engines: {node: '>=8.6.0'}
dependencies:
'@canvas/image-data': 1.0.0
to-data-view: 1.1.0
dev: false
/decode-ico/0.4.1:
resolution: {integrity: sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA==}
engines: {node: '>=8.6'}
dependencies:
'@canvas/image-data': 1.0.0
decode-bmp: 0.2.1
to-data-view: 1.1.0
dev: false
/decompress-response/6.0.0: /decompress-response/6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -1970,6 +2001,10 @@ packages:
resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}
dev: false dev: false
/to-data-view/1.1.0:
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
dev: false
/to-regex-range/5.0.1: /to-regex-range/5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@ -2056,3 +2091,13 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
dev: false dev: false
github.com/misskey-dev/sharp-read-bmp/c5fb82e26fa50fca8b3b2d7e22467626d7f61baa:
resolution: {tarball: https://codeload.github.com/misskey-dev/sharp-read-bmp/tar.gz/c5fb82e26fa50fca8b3b2d7e22467626d7f61baa}
name: sharp-read-bmp
version: 1.0.0
dependencies:
decode-bmp: 0.2.1
decode-ico: 0.4.1
sharp: 0.31.3
dev: false

View File

@ -8,6 +8,7 @@ import IPCIDR from 'ip-cidr';
import PrivateIp from 'private-ip'; import PrivateIp from 'private-ip';
import { StatusError } from './status-error.js'; import { StatusError } from './status-error.js';
import { getAgents } from './http.js'; import { getAgents } from './http.js';
import { parse } from 'content-disposition';
const pipeline = util.promisify(stream.pipeline); const pipeline = util.promisify(stream.pipeline);
@ -29,12 +30,17 @@ export const defaultDownloadConfig = {
...getAgents() ...getAgents()
} }
export async function downloadUrl(url: string, path: string, settings:DownloadConfig = defaultDownloadConfig): Promise<void> { export async function downloadUrl(url: string, path: string, settings:DownloadConfig = defaultDownloadConfig): Promise<{
filename: string;
}> {
if (process.env.NODE_ENV !== 'production') console.log(`Downloading ${url} to ${path} ...`); if (process.env.NODE_ENV !== 'production') console.log(`Downloading ${url} to ${path} ...`);
const timeout = 30 * 1000; const timeout = 30 * 1000;
const operationTimeout = 60 * 1000; const operationTimeout = 60 * 1000;
const urlObj = new URL(url);
let filename = urlObj.pathname.split('/').pop() ?? 'unknown';
const req = got.stream(url, { const req = got.stream(url, {
headers: { headers: {
'User-Agent': settings.userAgent, 'User-Agent': settings.userAgent,
@ -73,6 +79,14 @@ export async function downloadUrl(url: string, path: string, settings:DownloadCo
req.destroy(); req.destroy();
} }
} }
const contentDisposition = res.headers['content-disposition'];
if (contentDisposition != null) {
const parsed = parse(contentDisposition);
if (parsed.parameters.filename) {
filename = parsed.parameters.filename;
}
}
}).on('downloadProgress', (progress: Got.Progress) => { }).on('downloadProgress', (progress: Got.Progress) => {
if (progress.transferred > settings.maxSize) { if (progress.transferred > settings.maxSize) {
console.log(`maxSize exceeded (${progress.transferred} > ${settings.maxSize}) on downloadProgress`); console.log(`maxSize exceeded (${progress.transferred} > ${settings.maxSize}) on downloadProgress`);
@ -91,6 +105,10 @@ export async function downloadUrl(url: string, path: string, settings:DownloadCo
} }
if (process.env.NODE_ENV !== 'production') console.log(`Download finished: ${url}`); if (process.env.NODE_ENV !== 'production') console.log(`Download finished: ${url}`);
return {
filename,
}
} }
function isPrivateIp(ip: string, allowedPrivateNetworks: string[]): boolean { function isPrivateIp(ip: string, allowedPrivateNetworks: string[]): boolean {

View File

@ -66,8 +66,8 @@ import { FILE_TYPE_BROWSERSAFE } from './const.js';
const dictionary = { const dictionary = {
'safe-file': FILE_TYPE_BROWSERSAFE, 'safe-file': FILE_TYPE_BROWSERSAFE,
'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'], 'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'], 'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
}; };
export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime); export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime);

View File

@ -16,12 +16,13 @@ export type IImageStream = {
export type IImageStreamable = IImage | IImageStream; export type IImageStreamable = IImage | IImageStream;
export const webpDefault: sharp.WebpOptions = { export const webpDefault: sharp.WebpOptions = {
quality: 85, quality: 77,
alphaQuality: 95, alphaQuality: 95,
lossless: false, lossless: false,
nearLossless: false, nearLossless: false,
smartSubsample: true, smartSubsample: true,
mixed: true, mixed: true,
effort: 2,
}; };
export function convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { export function convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream {

View File

@ -6,13 +6,15 @@ import { dirname } from 'node:path';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import { createTemp } from './create-temp.js'; import { createTemp } from './create-temp.js';
import { FILE_TYPE_BROWSERSAFE } from './const.js'; import { FILE_TYPE_BROWSERSAFE } from './const.js';
import { IImageStreamable, convertToWebpStream, webpDefault } from './image-processor.js'; import { IImageStreamable, convertToWebpStream, webpDefault, convertSharpToWebpStream } from './image-processor.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
import { detectType, isMimeImage } from './file-info.js'; import { detectType, isMimeImage } from './file-info.js';
import sharp from 'sharp'; import sharp from 'sharp';
import { sharpBmp } from 'sharp-read-bmp';
import { StatusError } from './status-error.js'; import { StatusError } from './status-error.js';
import { DownloadConfig, defaultDownloadConfig, downloadUrl } from './download.js'; import { DownloadConfig, defaultDownloadConfig, downloadUrl } from './download.js';
import { getAgents } from './http.js'; import { getAgents } from './http.js';
import _contentDisposition from 'content-disposition';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
@ -152,12 +154,12 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; };
type: file.mime, type: file.mime,
}; };
} else { } else {
const data = sharp(file.path, { animated: !('static' in request.query) }) const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
.resize({ .resize({
height: 'emoji' in request.query ? 128 : 320, height: 'emoji' in request.query ? 128 : 320,
withoutEnlargement: true, withoutEnlargement: true,
}) })
.webp(webpDefault); .webp(webpDefault);
image = { image = {
data, data,
@ -166,11 +168,11 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; };
}; };
} }
} else if ('static' in request.query) { } else if ('static' in request.query) {
image = convertToWebpStream(file.path, 498, 280); image = convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422);
} else if ('preview' in request.query) { } else if ('preview' in request.query) {
image = convertToWebpStream(file.path, 200, 200); image = convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
} else if ('badge' in request.query) { } else if ('badge' in request.query) {
const mask = sharp(file.path) const mask = (await sharpBmp(file.path, file.mime))
.resize(96, 96, { .resize(96, 96, {
fit: 'inside', fit: 'inside',
withoutEnlargement: false, withoutEnlargement: false,
@ -230,6 +232,12 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; };
reply.header('Content-Type', image.type); reply.header('Content-Type', image.type);
reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition',
contentDisposition(
'inline',
correctFilename(file.filename, image.ext)
)
);
return reply.send(image.data); return reply.send(image.data);
} catch (e) { } catch (e) {
if ('cleanup' in file) file.cleanup(); if ('cleanup' in file) file.cleanup();
@ -238,11 +246,11 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; };
} }
async function downloadAndDetectTypeFromUrl(url: string): Promise< async function downloadAndDetectTypeFromUrl(url: string): Promise<
{ state: 'remote'; mime: string; ext: string | null; path: string; cleanup: () => void; } { state: 'remote'; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
> { > {
const [path, cleanup] = await createTemp(); const [path, cleanup] = await createTemp();
try { try {
await downloadUrl(url, path, config); const { filename } = await downloadUrl(url, path, config);
const { mime, ext } = await detectType(path); const { mime, ext } = await detectType(path);
@ -250,9 +258,29 @@ async function downloadAndDetectTypeFromUrl(url: string): Promise<
state: 'remote', state: 'remote',
mime, ext, mime, ext,
path, cleanup, path, cleanup,
filename: correctFilename(filename, ext),
} }
} catch (e) { } catch (e) {
cleanup(); cleanup();
throw e; throw e;
} }
} }
function correctFilename(filename: string, ext: string | null) {
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: 'inline' | 'attachment', filename: string): string {
const fallback = filename.replace(/[^\w.-]/g, '_');
return _contentDisposition(filename, { type, fallback });
}