From 808dacda41ee1fb5e8daf31a798dfa00dc7e4bfa Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 28 Feb 2023 08:03:15 +0000 Subject: [PATCH] 0.0.13 --- README.md | 6 ++-- SPECIFICATION.md | 81 +++++++++++++++++++++++++++++++++++++++++++++++ assets/dummy.png | Bin 0 -> 6285 bytes built/index.js | 14 ++++---- package.json | 2 +- src/index.ts | 11 +++---- 6 files changed, 94 insertions(+), 20 deletions(-) create mode 100644 SPECIFICATION.md create mode 100644 assets/dummy.png diff --git a/README.md b/README.md index d4f8a88..a72aaa3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Media Proxy for Misskey +[→ メディアプロキシの仕様](./SPECIFICATION.md) + Misskeyの/proxyが単体で動作します(Misskeyのコードがほぼそのまま移植されています)。 /proxyは画像ではないと403を返しますが、Media Proxyではそのまま内容を送信します。 @@ -61,10 +63,6 @@ export default { maxSize: 262144000, // CORS - // WARN: - // 'Access-Control-Allow-Origin'を'*'に設定した場合、要求のOriginヘッダーを応答します。 - // (Misskeyのアバタークロップに必要なため) - // Varyヘッダーが付加されるため、同じURLでもOriginごとに画像が生成されてしまうはずです。 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', diff --git a/SPECIFICATION.md b/SPECIFICATION.md new file mode 100644 index 0000000..2bd2e98 --- /dev/null +++ b/SPECIFICATION.md @@ -0,0 +1,81 @@ +# 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'`となっている。 + +### クエリの一覧 +#### 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)。 + +#### 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・高さ280pxに収まるサイズ以下に縮小される。 + +#### preview +存在すると、幅200px・高さ200pxに収まるサイズ以下のwebpが応答される。 + +#### badge +Webプッシュ通知のバッジに適したpngが応答される。 + +https://developer.mozilla.org/ja/docs/Web/API/Notification/badge + +サイズは96x96で、元画像がアルファチャンネルのみで表現される。 diff --git a/assets/dummy.png b/assets/dummy.png new file mode 100644 index 0000000000000000000000000000000000000000..39332b0c1beeda1edb90d78d25c16e7372aff030 GIT binary patch literal 6285 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 { - if (corsOrigin === '*') { - reply.header('Access-Control-Allow-Origin', request.headers.origin ?? '*'); - reply.header('Vary', 'Origin'); - } - else { - reply.header('Access-Control-Allow-Origin', corsOrigin); - } + reply.header('Access-Control-Allow-Origin', corsOrigin); reply.header('Access-Control-Allow-Headers', corsHeader); reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); reply.header('Content-Security-Policy', csp); @@ -160,6 +155,9 @@ async function proxyHandler(request, reply) { else if (file.mime === 'image/svg+xml') { image = convertToWebpStream(file.path, 2048, 2048); } + else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { + throw new StatusError('Rejected type', 403, 'Rejected type'); + } if (!image) { image = { data: fs.createReadStream(file.path), diff --git a/package.json b/package.json index 79eab8d..01dad90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey-media-proxy", - "version": "0.0.12", + "version": "0.0.13", "description": "The Media Proxy for Misskey", "main": "built/index.js", "packageManager": "pnpm@7.26.0", diff --git a/src/index.ts b/src/index.ts index ac91ab9..a2d3c9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ import { getAgents } from './http.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); -const assets = `${_dirname}/../../server/file/assets/`; +const assets = `${_dirname}/../assets/`; export type MediaProxyOptions = { ['Access-Control-Allow-Origin']?: string; @@ -73,12 +73,7 @@ export default function (fastify: FastifyInstance, options: MediaProxyOptions | 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) => { - if (corsOrigin === '*') { - reply.header('Access-Control-Allow-Origin', request.headers.origin ?? '*'); - reply.header('Vary', 'Origin'); - } else { - reply.header('Access-Control-Allow-Origin', corsOrigin); - } + reply.header('Access-Control-Allow-Origin', corsOrigin); reply.header('Access-Control-Allow-Headers', corsHeader); reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); reply.header('Content-Security-Policy', csp); @@ -206,6 +201,8 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; }; }; } else if (file.mime === 'image/svg+xml') { image = convertToWebpStream(file.path, 2048, 2048); + } else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { + throw new StatusError('Rejected type', 403, 'Rejected type'); } if (!image) {