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 0000000..39332b0 Binary files /dev/null and b/assets/dummy.png differ diff --git a/built/index.js b/built/index.js index 980d875..ac17872 100644 --- a/built/index.js +++ b/built/index.js @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import fastifyStatic from '@fastify/static'; import { createTemp } from './create-temp.js'; +import { FILE_TYPE_BROWSERSAFE } from './const.js'; import { convertToWebpStream, webpDefault } from './image-processor.js'; import { detectType, isMimeImage } from './file-info.js'; import sharp from 'sharp'; @@ -11,7 +12,7 @@ import { defaultDownloadConfig, downloadUrl } from './download.js'; 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/`; let config = defaultDownloadConfig; export function setMediaProxyConfig(setting) { const proxy = process.env.HTTP_PROXY ?? process.env.http_proxy; @@ -45,13 +46,7 @@ export default function (fastify, options, done) { 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) => { - 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) {