14 Commits

Author SHA1 Message Date
d87bc5f1d5 v0.0.23 2023-09-20 09:09:51 +00:00
0e79a81f3d v0.0.22 2023-05-10 13:17:35 +00:00
6bf5fbd794 0.0.21 fit contain 2023-04-19 14:28:09 +00:00
72431d805e 0.0.20 2023-04-15 15:07:46 +00:00
c3738a339a Merge branch 'master' of https://github.com/misskey-dev/media-proxy 2023-04-15 14:59:34 +00:00
99f0160e17 Content-Dispositionのパースでエラーが発生した場合にもダウンロードが完了するように 2023-04-15 14:59:24 +00:00
bd6f63b60e Update README.md 2023-04-13 11:57:42 +09:00
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
20 changed files with 791 additions and 604 deletions

View File

@ -4,14 +4,14 @@
Misskeyの/proxyが単体で動作しますMisskeyのコードがほぼそのまま移植されています Misskeyの/proxyが単体で動作しますMisskeyのコードがほぼそのまま移植されています
/proxyは画像ではないと403を返しますが、Media Proxyではそのまま内容を送信します。
**Fastifyプラグインとして動作する気がします。** **Fastifyプラグインとして動作する気がします。**
`pnpm start`は[fastify-cli](https://github.com/fastify/fastify-cli)が動作します。 `pnpm start`は[fastify-cli](https://github.com/fastify/fastify-cli)が動作します。
一応AWS Lambdaで動かす実装を用意しましたが、全くおすすめしません。 一応AWS Lambdaで動かす実装を用意しましたが、全くおすすめしません。
https://github.com/tamaina/media-proxy-lambda https://github.com/tamaina/media-proxy-lambda
Sharp.jsを使っているため、メモリアロケータにjemallocを指定することをお勧めします。
## Fastifyプラグインとして動作させる ## Fastifyプラグインとして動作させる
### npm install ### npm install
@ -38,6 +38,13 @@ git clone https://github.com/misskey-dev/media-proxy.git
cd media-proxy cd media-proxy
``` ```
### jemallocをインストール
Debian/Ubuntuのaptの場合
```
sudo apt install libjemalloc2
```
### pnpm install ### pnpm install
``` ```
NODE_ENV=production pnpm install NODE_ENV=production pnpm install
@ -78,14 +85,17 @@ export default {
適当にサーバーを公開してください。 適当にサーバーを公開してください。
ここではmediaproxy.example.comで公開するものとします。 ここではmediaproxy.example.comで公開するものとします。
メモ書き程度にsystemdでの開始方法を残しますが、もしかしたらAWS Lambdaとかで動かしたほうが楽かもしれません メモ書き程度にsystemdでの開始方法を残します。
サーバーレスだとsharp.jsが動かない可能性が高いため、そこはなんとかしてください サーバーレスだとsharp.jsが動かない可能性が高いため、そこはなんとかしてください
systemdサービスのファイルを作成… systemdサービスのファイルを作成…
/etc/systemd/system/misskey-proxy.service /etc/systemd/system/misskey-proxy.service
エディタで開き、以下のコードを貼り付けて保存(ユーザーやポートは適宜変更すること): エディタで開き、以下のコードを貼り付けて保存
ユーザーやポートは適宜変更すること。
また、arm64の場合`Environment="LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2"`のx86_64をaarch64に変更する必要がある。jemallocのパスはディストリビューションによって変わる可能性がある。
```systemd ```systemd
[Unit] [Unit]
@ -96,6 +106,7 @@ Type=simple
User=misskey User=misskey
ExecStart=/usr/bin/npm start ExecStart=/usr/bin/npm start
WorkingDirectory=/home/misskey/media-proxy WorkingDirectory=/home/misskey/media-proxy
Environment="LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2"
Environment="NODE_ENV=production" Environment="NODE_ENV=production"
Environment="PORT=3000" Environment="PORT=3000"
TimeoutSec=60 TimeoutSec=60

View File

@ -26,7 +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もしくはファイル名に基づいて挿入される。inlineが指定される。 Content-Dispositionは、filenameは元画像のContent-Disposition.filenameもしくはファイル名に基づいて挿入される。拡張子は適宜変更され、octet-streamの場合は拡張子として.unknownが付加される。inlineが指定される。
### クエリの一覧 ### クエリの一覧
#### url (必須) #### url (必須)
@ -52,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の場合は無変換で応答される。
@ -69,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が応答される。

View File

@ -30,6 +30,10 @@ export const FILE_TYPE_BROWSERSAFE = [
'video/webm', 'video/webm',
'audio/webm', 'audio/webm',
'audio/aac', 'audio/aac',
// see https://github.com/misskey-dev/misskey/pull/10686
'audio/flac',
'audio/wav',
// backward compatibility
'audio/x-flac', 'audio/x-flac',
'audio/vnd.wave', 'audio/vnd.wave',
]; ];

4
built/download.d.ts vendored
View File

@ -1,5 +1,5 @@
/// <reference types="node" /> /// <reference types="node" resolution-mode="require"/>
/// <reference types="node" /> /// <reference types="node" resolution-mode="require"/>
import * as http from 'node:http'; import * as http from 'node:http';
import * as https from 'node:https'; import * as https from 'node:https';
export type DownloadConfig = { export type DownloadConfig = {

View File

@ -1,9 +1,8 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as stream from 'node:stream'; import * as stream from 'node:stream';
import * as util from 'node:util'; import * as util from 'node:util';
import ipaddr from 'ipaddr.js';
import got, * as Got from 'got'; import got, * as Got from 'got';
import IPCIDR from 'ip-cidr';
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'; import { parse } from 'content-disposition';
@ -61,9 +60,14 @@ export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
} }
const contentDisposition = res.headers['content-disposition']; const contentDisposition = res.headers['content-disposition'];
if (contentDisposition != null) { if (contentDisposition != null) {
const parsed = parse(contentDisposition); try {
if (parsed.parameters.filename) { const parsed = parse(contentDisposition);
filename = parsed.parameters.filename; if (parsed.parameters.filename) {
filename = parsed.parameters.filename;
}
}
catch (e) {
console.log(`Failed to parse content-disposition: ${contentDisposition}\n${e}`);
} }
} }
}).on('downloadProgress', (progress) => { }).on('downloadProgress', (progress) => {
@ -90,11 +94,11 @@ export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
}; };
} }
function isPrivateIp(ip, allowedPrivateNetworks) { function isPrivateIp(ip, allowedPrivateNetworks) {
const parsedIp = ipaddr.parse(ip);
for (const net of allowedPrivateNetworks ?? []) { for (const net of allowedPrivateNetworks ?? []) {
const cidr = new IPCIDR(net); if (parsedIp.match(ipaddr.parseCIDR(net))) {
if (cidr.contains(ip)) {
return false; return false;
} }
} }
return PrivateIp(ip) ?? false; return parsedIp.range() !== 'unicast';
} }

View File

@ -30,7 +30,7 @@ export async function detectType(path) {
return TYPE_OCTET_STREAM; return TYPE_OCTET_STREAM;
} }
return { return {
mime: type.mime, mime: fixMime(type.mime),
ext: type.ext, ext: type.ext,
}; };
} }
@ -46,7 +46,7 @@ async function checkSvg(path) {
const size = await getFileSize(path); const size = await getFileSize(path);
if (size > 1 * 1024 * 1024) if (size > 1 * 1024 * 1024)
return false; return false;
return isSvg(fs.readFileSync(path)); return isSvg(fs.readFileSync(path, { encoding: 'utf-8' }));
} }
catch { catch {
return false; return false;
@ -55,7 +55,17 @@ 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);
function fixMime(mime) {
// see https://github.com/misskey-dev/misskey/pull/10686
if (mime === "audio/x-flac") {
return "audio/flac";
}
if (mime === "audio/vnd.wave") {
return "audio/wav";
}
return mime;
}

4
built/http.d.ts vendored
View File

@ -1,5 +1,5 @@
/// <reference types="node" /> /// <reference types="node" resolution-mode="require"/>
/// <reference types="node" /> /// <reference types="node" resolution-mode="require"/>
import * as http from 'node:http'; import * as http from 'node:http';
import * as https from 'node:https'; import * as https from 'node:https';
export declare function getAgents(proxy?: string): { export declare function getAgents(proxy?: string): {

View File

@ -1,5 +1,5 @@
/// <reference types="node" /> /// <reference types="node" resolution-mode="require"/>
/// <reference types="node" /> /// <reference types="node" resolution-mode="require"/>
import sharp from 'sharp'; import sharp from 'sharp';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
export type IImage = { export type IImage = {

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);

4
built/index.d.ts vendored
View File

@ -1,5 +1,5 @@
/// <reference types="node" /> /// <reference types="node" resolution-mode="require"/>
/// <reference types="node" /> /// <reference types="node" resolution-mode="require"/>
import * as http from 'node:http'; import * as http from 'node:http';
import * as https from 'node:https'; import * as https from 'node:https';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';

View File

@ -4,9 +4,10 @@ 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';
@ -107,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,
@ -121,15 +122,16 @@ 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: 'contain',
position: 'centre',
withoutEnlargement: false, withoutEnlargement: false,
}) })
.greyscale() .greyscale()
@ -183,7 +185,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', file.filename)); 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) {
@ -210,15 +212,16 @@ async function downloadAndDetectTypeFromUrl(url) {
} }
} }
function correctFilename(filename, ext) { function correctFilename(filename, ext) {
if (!ext) const dotExt = ext ? `.${ext}` : '.unknown';
return filename;
const dotExt = `.${ext}`;
if (filename.endsWith(dotExt)) { if (filename.endsWith(dotExt)) {
return filename; return filename;
} }
if (ext === 'jpg' && filename.endsWith('.jpeg')) { if (ext === 'jpg' && filename.endsWith('.jpeg')) {
return filename; return filename;
} }
if (ext === 'tif' && filename.endsWith('.tiff')) {
return filename;
}
return `${filename}${dotExt}`; return `${filename}${dotExt}`;
} }
function contentDisposition(type, filename) { function contentDisposition(type, filename) {

View File

@ -1,9 +1,9 @@
{ {
"name": "misskey-media-proxy", "name": "misskey-media-proxy",
"version": "0.0.14", "version": "0.0.23",
"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@8.7.5",
"type": "module", "type": "module",
"files": [ "files": [
"built", "built",
@ -26,27 +26,28 @@
}, },
"homepage": "https://github.com/misskey-dev/media-proxy#readme", "homepage": "https://github.com/misskey-dev/media-proxy#readme",
"devDependencies": { "devDependencies": {
"@swc/cli": "^0.1.61", "@swc/cli": "^0.1.62",
"@swc/core": "^1.3.32", "@swc/core": "^1.3.86",
"@types/content-disposition": "^0.5.5", "@types/content-disposition": "^0.5.6",
"@types/node": "^18.11.19", "@types/node": "^20.6.3",
"@types/sharp": "^0.31.1", "@types/tmp": "^0.2.4",
"@types/tmp": "^0.2.3", "typescript": "^5.2.2"
"typescript": "^4.9.5"
}, },
"dependencies": { "dependencies": {
"@fastify/static": "^6.8.0", "@fastify/static": "^6.11.2",
"cacheable-lookup": "^7.0.0", "cacheable-lookup": "^7.0.0",
"content-disposition": "^0.5.4", "content-disposition": "^0.5.4",
"fastify": "^4.12.0", "fastify": "^4.23.2",
"fastify-cli": "^5.7.1", "fastify-cli": "^5.8.0",
"file-type": "^18.2.0", "file-type": "^18.5.0",
"got": "^12.5.3", "got": "^13.0.0",
"hpagent": "^1.2.0", "hpagent": "^1.2.0",
"ip-cidr": "^3.1.0", "ip-cidr": "^3.1.0",
"is-svg": "^4.3.2", "ipaddr.js": "^2.1.0",
"private-ip": "^3.0.0", "is-svg": "^5.0.0",
"sharp": "^0.31.3", "private-ip": "^3.0.1",
"sharp": "^0.32.6",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"tmp": "^0.2.1" "tmp": "^0.2.1"
} }
} }

1153
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,11 @@ export const FILE_TYPE_BROWSERSAFE = [
'audio/webm', 'audio/webm',
'audio/aac', 'audio/aac',
// see https://github.com/misskey-dev/misskey/pull/10686
'audio/flac',
'audio/wav',
// backward compatibility
'audio/x-flac', 'audio/x-flac',
'audio/vnd.wave', 'audio/vnd.wave',
]; ];

View File

@ -3,9 +3,8 @@ import * as stream from 'node:stream';
import * as util from 'node:util'; import * as util from 'node:util';
import * as http from 'node:http'; import * as http from 'node:http';
import * as https from 'node:https'; import * as https from 'node:https';
import ipaddr from 'ipaddr.js';
import got, * as Got from 'got'; import got, * as Got from 'got';
import IPCIDR from 'ip-cidr';
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'; import { parse } from 'content-disposition';
@ -82,9 +81,13 @@ export async function downloadUrl(url: string, path: string, settings:DownloadCo
const contentDisposition = res.headers['content-disposition']; const contentDisposition = res.headers['content-disposition'];
if (contentDisposition != null) { if (contentDisposition != null) {
const parsed = parse(contentDisposition); try {
if (parsed.parameters.filename) { const parsed = parse(contentDisposition);
filename = parsed.parameters.filename; if (parsed.parameters.filename) {
filename = parsed.parameters.filename;
}
} catch (e) {
console.log(`Failed to parse content-disposition: ${contentDisposition}\n${e}`);
} }
} }
}).on('downloadProgress', (progress: Got.Progress) => { }).on('downloadProgress', (progress: Got.Progress) => {
@ -112,12 +115,13 @@ export async function downloadUrl(url: string, path: string, settings:DownloadCo
} }
function isPrivateIp(ip: string, allowedPrivateNetworks: string[]): boolean { function isPrivateIp(ip: string, allowedPrivateNetworks: string[]): boolean {
const parsedIp = ipaddr.parse(ip);
for (const net of allowedPrivateNetworks ?? []) { for (const net of allowedPrivateNetworks ?? []) {
const cidr = new IPCIDR(net); if (parsedIp.match(ipaddr.parseCIDR(net))) {
if (cidr.contains(ip)) {
return false; return false;
} }
} }
return PrivateIp(ip) ?? false; return parsedIp.range() !== 'unicast';
} }

View File

@ -1,5 +1,6 @@
import fs from 'node:fs'; import fs from 'node:fs';
import { fileTypeFromFile } from 'file-type'; import { fileTypeFromFile } from 'file-type';
import type { MimeType } from 'file-type';
import isSvg from 'is-svg'; import isSvg from 'is-svg';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
@ -39,7 +40,7 @@ export async function detectType(path: string): Promise<{
} }
return { return {
mime: type.mime, mime: fixMime(type.mime),
ext: type.ext, ext: type.ext,
}; };
} }
@ -56,7 +57,7 @@ async function checkSvg(path: string) {
try { try {
const size = await getFileSize(path); const size = await getFileSize(path);
if (size > 1 * 1024 * 1024) return false; if (size > 1 * 1024 * 1024) return false;
return isSvg(fs.readFileSync(path)); return isSvg(fs.readFileSync(path, { encoding: 'utf-8' }));
} catch { } catch {
return false; return false;
} }
@ -66,8 +67,20 @@ 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);
function fixMime(mime: string | MimeType): string {
// see https://github.com/misskey-dev/misskey/pull/10686
if (mime === "audio/x-flac") {
return "audio/flac";
}
if (mime === "audio/vnd.wave") {
return "audio/wav";
}
return mime;
}

View File

@ -2,6 +2,7 @@ import * as http from 'node:http';
import * as https from 'node:https'; import * as https from 'node:https';
import CacheableLookup from 'cacheable-lookup'; import CacheableLookup from 'cacheable-lookup';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
import { LookupFunction } from 'node:net';
const cache = new CacheableLookup({ const cache = new CacheableLookup({
maxTtl: 3600, // 1hours maxTtl: 3600, // 1hours
@ -12,13 +13,13 @@ const cache = new CacheableLookup({
const _http = new http.Agent({ const _http = new http.Agent({
keepAlive: true, keepAlive: true,
keepAliveMsecs: 30 * 1000, keepAliveMsecs: 30 * 1000,
lookup: cache.lookup, lookup: cache.lookup as unknown as LookupFunction,
} as http.AgentOptions); } as http.AgentOptions);
const _https = new https.Agent({ const _https = new https.Agent({
keepAlive: true, keepAlive: true,
keepAliveMsecs: 30 * 1000, keepAliveMsecs: 30 * 1000,
lookup: cache.lookup, lookup: cache.lookup as unknown as LookupFunction,
} as https.AgentOptions); } as https.AgentOptions);
export function getAgents(proxy?: string) { export function getAgents(proxy?: string) {

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,10 +6,11 @@ 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';
@ -153,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,
@ -167,13 +168,14 @@ 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: 'contain',
position: 'centre',
withoutEnlargement: false, withoutEnlargement: false,
}) })
.greyscale() .greyscale()
@ -231,7 +233,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', file.filename)); 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();
@ -261,15 +268,16 @@ async function downloadAndDetectTypeFromUrl(url: string): Promise<
} }
function correctFilename(filename: string, ext: string | null) { function correctFilename(filename: string, ext: string | null) {
if (!ext) return filename; const dotExt = ext ? `.${ext}` : '.unknown';
const dotExt = `.${ext}`;
if (filename.endsWith(dotExt)) { if (filename.endsWith(dotExt)) {
return filename; return filename;
} }
if (ext === 'jpg' && filename.endsWith('.jpeg')) { if (ext === 'jpg' && filename.endsWith('.jpeg')) {
return filename; return filename;
} }
if (ext === 'tif' && filename.endsWith('.tiff')) {
return filename;
}
return `${filename}${dotExt}`; return `${filename}${dotExt}`;
} }

View File

@ -10,8 +10,8 @@
"declaration": true, "declaration": true,
"sourceMap": false, "sourceMap": false,
"target": "es2021", "target": "es2021",
"module": "esnext", "module": "nodenext",
"moduleResolution": "node", "moduleResolution": "nodenext",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"removeComments": false, "removeComments": false,
"noLib": false, "noLib": false,
@ -36,6 +36,7 @@
], ],
"typeRoots": [ "typeRoots": [
"./node_modules/@types", "./node_modules/@types",
"./node_modules",
"./src/@types" "./src/@types"
], ],
"lib": [ "lib": [