mirror of
https://github.com/misskey-dev/media-proxy.git
synced 2025-08-07 08:43:53 +09:00
Compare commits
24 Commits
Author | SHA1 | Date | |
---|---|---|---|
a359a64288 | |||
16a1900c3c | |||
6b42019d5f | |||
d87bc5f1d5 | |||
0e79a81f3d | |||
6bf5fbd794 | |||
72431d805e | |||
c3738a339a | |||
99f0160e17 | |||
bd6f63b60e | |||
384c51569f | |||
57aa87f370 | |||
d86823e32d | |||
405e6a5adb | |||
78cf2469d4 | |||
969d27e1b3 | |||
d5f5f4023c | |||
30c8088b8e | |||
4d3869020a | |||
4e2230d7cf | |||
cfaf017c15 | |||
808dacda41 | |||
0f65312eef | |||
495f655973 |
@ -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.'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
};
|
|
27
README.md
27
README.md
@ -1,8 +1,8 @@
|
|||||||
# Media Proxy for Misskey
|
# Media Proxy for Misskey
|
||||||
|
|
||||||
Misskeyの/proxyが単体で動作します(Misskeyのコードがほぼそのまま移植されています)。
|
[→ メディアプロキシの仕様](./SPECIFICATION.md)
|
||||||
|
|
||||||
/proxyは画像ではないと403を返しますが、Media Proxyではそのまま内容を送信します。
|
Misskeyの/proxyが単体で動作します(Misskeyのコードがほぼそのまま移植されています)。
|
||||||
|
|
||||||
**Fastifyプラグインとして動作する気がします。**
|
**Fastifyプラグインとして動作する気がします。**
|
||||||
`pnpm start`は[fastify-cli](https://github.com/fastify/fastify-cli)が動作します。
|
`pnpm start`は[fastify-cli](https://github.com/fastify/fastify-cli)が動作します。
|
||||||
@ -10,6 +10,8 @@ Misskeyの/proxyが単体で動作します(Misskeyのコードがほぼその
|
|||||||
一応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
|
||||||
|
|
||||||
@ -36,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
|
||||||
@ -61,11 +70,11 @@ export default {
|
|||||||
maxSize: 262144000,
|
maxSize: 262144000,
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
['Access-Control-Allow-Origin']: '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
['Access-Control-Allow-Headers']: '*',
|
'Access-Control-Allow-Headers': '*',
|
||||||
|
|
||||||
// CSP
|
// CSP
|
||||||
['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'`,
|
||||||
|
|
||||||
// フォワードプロキシ
|
// フォワードプロキシ
|
||||||
// proxy: 'http://127.0.0.1:3128'
|
// proxy: 'http://127.0.0.1:3128'
|
||||||
@ -76,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]
|
||||||
@ -94,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
|
||||||
|
85
SPECIFICATION.md
Normal file
85
SPECIFICATION.md
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# Misskeyメディアプロキシ仕様書
|
||||||
|
|
||||||
|
## メディアプロキシの種類と目的
|
||||||
|
Misskeyメディアプロキシは、リモートのファイルをインスタンス管理者が管理するドメインでプロキシ配信し、また、縮小・加工された画像を提供するためのアプリケーションである。
|
||||||
|
|
||||||
|
Misskeyサーバー本体の/proxy/で提供されている「本体メディアプロキシ (local media proxy)」と、[github.com/misskey-dev/media-proxy](https://github.com/misskey-dev/media-proxy)で配布されている「外部メディアプロキシ (external media proxy)」がある。
|
||||||
|
|
||||||
|
外部メディアプロキシを設定・使用することで、本体のサーバー負荷を軽減できる。また、複数のインスタンスで外部プロキシを共用すると、さらなる負荷軽減が期待できる。
|
||||||
|
|
||||||
|
## 外部メディアプロキシの設定と使用
|
||||||
|
外部メディアプロキシを設定するには、[README.md](./README.md)に記載されている通りインストールする。
|
||||||
|
|
||||||
|
外部メディアプロキシが設定されている場合、本体メディアプロキシは外部メディアプロキシへ301リダイレクトを返答する(originクエリが指定されている場合を除く)。
|
||||||
|
|
||||||
|
Misskeyサーバーのapi/metaの応答に、使用するべきメディアプロキシのURLを示す`mediaProxy`プロパティが存在する。
|
||||||
|
外部メディアプロキシが指定されているならそのURLが、指定されていなければ本体メディアプロキシ(/proxy/)のURLが入っている。
|
||||||
|
本体メディアプロキシはリダイレクトを行うものの、Misskeyクライアントは`mediaProxy`の値に応じて適切なメディアプロキシへ直接要求を行うべきである。
|
||||||
|
|
||||||
|
メディアプロキシへは、クエリ文字列によって命令を行う。
|
||||||
|
|
||||||
|
拡張子によってキャッシュの挙動を変えるCDNがあるため、image.webp、avatar.webp、static.webpなどの適当なファイル名を付加するべきである。
|
||||||
|
例:
|
||||||
|
`https://example.com/proxy/image.webp?url=https%3A%2F%2F......`
|
||||||
|
|
||||||
|
Acceptヘッダーは無視される。
|
||||||
|
Cache-Controlは、正常なレスポンスの場合`max-age=31536000, immutable`、エラーレスポンスの場合`max-age=300`である。
|
||||||
|
Content-Typeは、ファイルの内容について適切なものが挿入される。
|
||||||
|
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を指定する。
|
||||||
|
指定がなかった場合はHTTPコード400が返される。
|
||||||
|
|
||||||
|
https://www.google.com/images/errors/robot.png をプロキシする場合:
|
||||||
|
`https://example.com/proxy/image.webp?url=https%3A%2F%2Fwww.google.com%2Fimages%2Ferrors%2Frobot.png`
|
||||||
|
|
||||||
|
#### origin (本体のみ)
|
||||||
|
存在すると、外部メディアプロキシへのリダイレクトを行わない。
|
||||||
|
|
||||||
|
`https://example.com/proxy/image.webp?url=https%3A%2F%2F...&origin=1`
|
||||||
|
|
||||||
|
「存在すると」というのは、Fastifyで`'origin' in request.query`がtureになる場合という意味である。以下同様。
|
||||||
|
|
||||||
|
#### fallback
|
||||||
|
存在すると、元画像に到達できなかったり画像の変換中にエラーが起きたりした場合、正常なレスポンス(Cache-Controlは`max-age=300`)としてフォールバック画像(カラーバー)が表示される。
|
||||||
|
|
||||||
|
#### 変換クエリが存在しない場合の挙動
|
||||||
|
次の項目からは変換形式を指定するクエリとなっている。
|
||||||
|
|
||||||
|
変換形式が指定されていなかった場合は、画像ファイルもしくは許可されたファイル(FILE_TYPE_BROWSERSAFE)である場合のみプロキシ(ファイルの再配信)が行われる。
|
||||||
|
ただし、svgは、webpに変換される(最大サイズ2048x2048)。
|
||||||
|
|
||||||
|
#### 変換クエリ付加時の挙動
|
||||||
|
一方、以下の変換クエリが指定されているが、元ファイルがsharp.jsで変換できない形式の場合、404が返される。
|
||||||
|
|
||||||
|
#### emoji
|
||||||
|
存在すると、高さ128px以下のwebpが応答される。
|
||||||
|
ただし、sharp.jsの都合により、元画像がapngの場合は無変換で応答される。
|
||||||
|
|
||||||
|
`https://example.com/proxy/emoji.webp?url=https%3A%2F%2F...&emoji=1`
|
||||||
|
|
||||||
|
「以下」というのは、元画像がこれ未満だった場合は拡大を行わないという意味である。以下同様。
|
||||||
|
|
||||||
|
#### avatar
|
||||||
|
存在すると、高さ320px以下のwebpが応答される。
|
||||||
|
ただし、sharp.jsの都合により、元画像がapngの場合は無変換で応答される。
|
||||||
|
|
||||||
|
`https://example.com/proxy/avatar.webp?url=https%3A%2F%2F...&avatar=1`
|
||||||
|
|
||||||
|
#### static
|
||||||
|
存在すると、アニメーション画像では最初のフレームのみの静止画のwebpが応答される。
|
||||||
|
|
||||||
|
emojiまたはavatarとstaticが同時に指定された場合は、それぞれに応じた高さが、指定されていない場合は幅498px・高さ422pxに収まるサイズ以下に縮小される。
|
||||||
|
|
||||||
|
#### preview
|
||||||
|
存在すると、幅200px・高さ200pxに収まるサイズ以下のwebpが応答される。
|
||||||
|
|
||||||
|
#### badge
|
||||||
|
Webプッシュ通知のバッジに適したpngが応答される。
|
||||||
|
|
||||||
|
https://developer.mozilla.org/ja/docs/Web/API/Notification/badge
|
||||||
|
|
||||||
|
サイズは96x96で、元画像がアルファチャンネルのみで表現される。
|
BIN
assets/dummy.png
Normal file
BIN
assets/dummy.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.1 KiB |
@ -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',
|
||||||
];
|
];
|
||||||
|
8
built/download.d.ts
vendored
8
built/download.d.ts
vendored
@ -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 = {
|
||||||
@ -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;
|
||||||
|
}>;
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
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';
|
||||||
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 +19,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,
|
||||||
@ -27,7 +29,7 @@ export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
|
|||||||
lookup: timeout,
|
lookup: timeout,
|
||||||
connect: timeout,
|
connect: timeout,
|
||||||
secureConnect: timeout,
|
secureConnect: timeout,
|
||||||
socket: timeout,
|
socket: timeout, // read timeout
|
||||||
response: timeout,
|
response: timeout,
|
||||||
send: timeout,
|
send: timeout,
|
||||||
request: operationTimeout, // whole operation timeout
|
request: operationTimeout, // whole operation timeout
|
||||||
@ -56,6 +58,18 @@ export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
|
|||||||
req.destroy();
|
req.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const contentDisposition = res.headers['content-disposition'];
|
||||||
|
if (contentDisposition != null) {
|
||||||
|
try {
|
||||||
|
const parsed = parse(contentDisposition);
|
||||||
|
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) => {
|
||||||
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,13 +89,16 @@ 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) {
|
||||||
|
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';
|
||||||
}
|
}
|
||||||
|
@ -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
4
built/http.d.ts
vendored
@ -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): {
|
||||||
|
@ -3,8 +3,8 @@ 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';
|
||||||
const cache = new CacheableLookup({
|
const cache = new CacheableLookup({
|
||||||
maxTtl: 3600,
|
maxTtl: 3600, // 1hours
|
||||||
errorTtl: 30,
|
errorTtl: 30, // 30secs
|
||||||
lookup: false, // nativeのdns.lookupにfallbackしない
|
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||||
});
|
});
|
||||||
const _http = new http.Agent({
|
const _http = new http.Agent({
|
||||||
|
4
built/image-processor.d.ts
vendored
4
built/image-processor.d.ts
vendored
@ -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 = {
|
||||||
|
@ -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
4
built/index.d.ts
vendored
@ -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';
|
||||||
|
@ -3,15 +3,18 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { dirname } from 'node:path';
|
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 { convertToWebpStream, webpDefault } from './image-processor.js';
|
import { FILE_TYPE_BROWSERSAFE } from './const.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 '@misskey-dev/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}/../../server/file/assets/`;
|
const assets = `${_dirname}/../assets/`;
|
||||||
let config = defaultDownloadConfig;
|
let config = defaultDownloadConfig;
|
||||||
export function setMediaProxyConfig(setting) {
|
export function setMediaProxyConfig(setting) {
|
||||||
const proxy = process.env.HTTP_PROXY ?? process.env.http_proxy;
|
const proxy = process.env.HTTP_PROXY ?? process.env.http_proxy;
|
||||||
@ -41,11 +44,14 @@ export function setMediaProxyConfig(setting) {
|
|||||||
}
|
}
|
||||||
export default function (fastify, options, done) {
|
export default function (fastify, options, done) {
|
||||||
setMediaProxyConfig(options);
|
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) => {
|
fastify.addHook('onRequest', (request, reply, done) => {
|
||||||
reply.header('Access-Control-Allow-Origin', options['Access-Control-Allow-Origin'] ?? '*');
|
reply.header('Access-Control-Allow-Origin', corsOrigin);
|
||||||
reply.header('Access-Control-Allow-Headers', options['Access-Control-Allow-Headers'] ?? '*');
|
reply.header('Access-Control-Allow-Headers', corsHeader);
|
||||||
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||||
reply.header('Content-Security-Policy', options['Content-Security-Policy'] ?? `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`);
|
reply.header('Content-Security-Policy', csp);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
fastify.register(fastifyStatic, {
|
fastify.register(fastifyStatic, {
|
||||||
@ -102,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,
|
||||||
@ -116,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()
|
||||||
@ -151,6 +158,9 @@ async function proxyHandler(request, reply) {
|
|||||||
else if (file.mime === 'image/svg+xml') {
|
else if (file.mime === 'image/svg+xml') {
|
||||||
image = convertToWebpStream(file.path, 2048, 2048);
|
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) {
|
if (!image) {
|
||||||
image = {
|
image = {
|
||||||
data: fs.createReadStream(file.path),
|
data: fs.createReadStream(file.path),
|
||||||
@ -175,6 +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', correctFilename(file.filename, image.ext)));
|
||||||
return reply.send(image.data);
|
return reply.send(image.data);
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
@ -186,12 +197,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) {
|
||||||
@ -199,3 +211,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 });
|
||||||
|
}
|
||||||
|
35
package.json
35
package.json
@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "misskey-media-proxy",
|
"name": "misskey-media-proxy",
|
||||||
"version": "0.0.11",
|
"version": "0.0.24",
|
||||||
"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,25 +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.63",
|
||||||
"@swc/core": "^1.3.32",
|
"@swc/core": "^1.3.104",
|
||||||
"@types/node": "^18.11.19",
|
"@types/content-disposition": "^0.5.8",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/node": "^20.11.5",
|
||||||
"@types/tmp": "^0.2.3",
|
"@types/tmp": "^0.2.6",
|
||||||
"typescript": "^4.9.5"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/static": "^6.8.0",
|
"@fastify/static": "^6.12.0",
|
||||||
|
"@misskey-dev/sharp-read-bmp": "^1.1.1",
|
||||||
"cacheable-lookup": "^7.0.0",
|
"cacheable-lookup": "^7.0.0",
|
||||||
"fastify": "^4.12.0",
|
"content-disposition": "^0.5.4",
|
||||||
"fastify-cli": "^5.7.1",
|
"fastify": "^4.25.2",
|
||||||
"file-type": "^18.2.0",
|
"fastify-cli": "^6.0.1",
|
||||||
"got": "^12.5.3",
|
"file-type": "^19.0.0",
|
||||||
|
"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",
|
||||||
"tmp": "^0.2.1"
|
"tmp": "^0.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1211
pnpm-lock.yaml
generated
1211
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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',
|
||||||
];
|
];
|
||||||
|
@ -3,11 +3,11 @@ 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';
|
||||||
|
|
||||||
const pipeline = util.promisify(stream.pipeline);
|
const pipeline = util.promisify(stream.pipeline);
|
||||||
|
|
||||||
@ -29,12 +29,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 +78,18 @@ export async function downloadUrl(url: string, path: string, settings:DownloadCo
|
|||||||
req.destroy();
|
req.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentDisposition = res.headers['content-disposition'];
|
||||||
|
if (contentDisposition != null) {
|
||||||
|
try {
|
||||||
|
const parsed = parse(contentDisposition);
|
||||||
|
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) => {
|
||||||
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,15 +108,20 @@ 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 {
|
||||||
|
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';
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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 {
|
||||||
|
69
src/index.ts
69
src/index.ts
@ -6,18 +6,20 @@ 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 '@misskey-dev/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);
|
||||||
|
|
||||||
const assets = `${_dirname}/../../server/file/assets/`;
|
const assets = `${_dirname}/../assets/`;
|
||||||
|
|
||||||
export type MediaProxyOptions = {
|
export type MediaProxyOptions = {
|
||||||
['Access-Control-Allow-Origin']?: string;
|
['Access-Control-Allow-Origin']?: string;
|
||||||
@ -68,11 +70,15 @@ export function setMediaProxyConfig(setting?: MediaProxyOptions | null) {
|
|||||||
export default function (fastify: FastifyInstance, options: MediaProxyOptions | null | undefined, done: (err?: Error) => void) {
|
export default function (fastify: FastifyInstance, options: MediaProxyOptions | null | undefined, done: (err?: Error) => void) {
|
||||||
setMediaProxyConfig(options);
|
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) => {
|
fastify.addHook('onRequest', (request, reply, done) => {
|
||||||
reply.header('Access-Control-Allow-Origin', options!['Access-Control-Allow-Origin'] ?? '*');
|
reply.header('Access-Control-Allow-Origin', corsOrigin);
|
||||||
reply.header('Access-Control-Allow-Headers', options!['Access-Control-Allow-Headers'] ?? '*');
|
reply.header('Access-Control-Allow-Headers', corsHeader);
|
||||||
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||||
reply.header('Content-Security-Policy', options!['Content-Security-Policy'] ?? `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`);
|
reply.header('Content-Security-Policy', csp);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -148,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,
|
||||||
@ -162,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()
|
||||||
@ -197,6 +204,8 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; };
|
|||||||
};
|
};
|
||||||
} else if (file.mime === 'image/svg+xml') {
|
} else if (file.mime === 'image/svg+xml') {
|
||||||
image = convertToWebpStream(file.path, 2048, 2048);
|
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) {
|
if (!image) {
|
||||||
@ -224,6 +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',
|
||||||
|
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();
|
||||||
@ -232,11 +247,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);
|
||||||
|
|
||||||
@ -244,9 +259,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 });
|
||||||
|
}
|
||||||
|
@ -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": [
|
||||||
|
Reference in New Issue
Block a user