mirror of
https://github.com/misskey-dev/media-proxy.git
synced 2025-08-07 08:43:53 +09:00
Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
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.'
|
||||
}
|
||||
]
|
||||
},
|
||||
};
|
@ -1,5 +1,7 @@
|
||||
# Media Proxy for Misskey
|
||||
|
||||
[→ メディアプロキシの仕様](./SPECIFICATION.md)
|
||||
|
||||
Misskeyの/proxyが単体で動作します(Misskeyのコードがほぼそのまま移植されています)。
|
||||
|
||||
/proxyは画像ではないと403を返しますが、Media Proxyではそのまま内容を送信します。
|
||||
@ -61,11 +63,11 @@ export default {
|
||||
maxSize: 262144000,
|
||||
|
||||
// CORS
|
||||
['Access-Control-Allow-Origin']: '*',
|
||||
['Access-Control-Allow-Headers']: '*',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
|
||||
// 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'
|
||||
|
82
SPECIFICATION.md
Normal file
82
SPECIFICATION.md
Normal file
@ -0,0 +1,82 @@
|
||||
# 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もしくはファイル名に基づいて挿入される。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で、元画像がアルファチャンネルのみで表現される。
|
BIN
assets/dummy.png
Normal file
BIN
assets/dummy.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.1 KiB |
4
built/download.d.ts
vendored
4
built/download.d.ts
vendored
@ -19,4 +19,6 @@ export declare const defaultDownloadConfig: {
|
||||
maxSize: number;
|
||||
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;
|
||||
}>;
|
||||
|
@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr';
|
||||
import PrivateIp from 'private-ip';
|
||||
import { StatusError } from './status-error.js';
|
||||
import { getAgents } from './http.js';
|
||||
import { parse } from 'content-disposition';
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
export const defaultDownloadConfig = {
|
||||
userAgent: `MisskeyMediaProxy/0.0.0`,
|
||||
@ -19,6 +20,8 @@ export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
|
||||
console.log(`Downloading ${url} to ${path} ...`);
|
||||
const timeout = 30 * 1000;
|
||||
const operationTimeout = 60 * 1000;
|
||||
const urlObj = new URL(url);
|
||||
let filename = urlObj.pathname.split('/').pop() ?? 'unknown';
|
||||
const req = got.stream(url, {
|
||||
headers: {
|
||||
'User-Agent': settings.userAgent,
|
||||
@ -56,6 +59,13 @@ export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
|
||||
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) => {
|
||||
if (progress.transferred > settings.maxSize) {
|
||||
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')
|
||||
console.log(`Download finished: ${url}`);
|
||||
return {
|
||||
filename,
|
||||
};
|
||||
}
|
||||
function isPrivateIp(ip, allowedPrivateNetworks) {
|
||||
for (const net of allowedPrivateNetworks ?? []) {
|
||||
|
@ -3,15 +3,17 @@ 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';
|
||||
import { StatusError } from './status-error.js';
|
||||
import { defaultDownloadConfig, downloadUrl } from './download.js';
|
||||
import { getAgents } from './http.js';
|
||||
import _contentDisposition from 'content-disposition';
|
||||
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;
|
||||
@ -41,11 +43,14 @@ export function setMediaProxyConfig(setting) {
|
||||
}
|
||||
export default function (fastify, options, done) {
|
||||
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) => {
|
||||
reply.header('Access-Control-Allow-Origin', options['Access-Control-Allow-Origin'] ?? '*');
|
||||
reply.header('Access-Control-Allow-Headers', options['Access-Control-Allow-Headers'] ?? '*');
|
||||
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', options['Content-Security-Policy'] ?? `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`);
|
||||
reply.header('Content-Security-Policy', csp);
|
||||
done();
|
||||
});
|
||||
fastify.register(fastifyStatic, {
|
||||
@ -151,6 +156,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),
|
||||
@ -175,6 +183,7 @@ async function proxyHandler(request, reply) {
|
||||
}
|
||||
reply.header('Content-Type', image.type);
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
|
||||
return reply.send(image.data);
|
||||
}
|
||||
catch (e) {
|
||||
@ -186,12 +195,13 @@ async function proxyHandler(request, reply) {
|
||||
async function downloadAndDetectTypeFromUrl(url) {
|
||||
const [path, cleanup] = await createTemp();
|
||||
try {
|
||||
await downloadUrl(url, path, config);
|
||||
const { filename } = await downloadUrl(url, path, config);
|
||||
const { mime, ext } = await detectType(path);
|
||||
return {
|
||||
state: 'remote',
|
||||
mime, ext,
|
||||
path, cleanup,
|
||||
filename: correctFilename(filename, ext),
|
||||
};
|
||||
}
|
||||
catch (e) {
|
||||
@ -199,3 +209,19 @@ async function downloadAndDetectTypeFromUrl(url) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
function correctFilename(filename, ext) {
|
||||
if (!ext)
|
||||
return filename;
|
||||
const dotExt = `.${ext}`;
|
||||
if (filename.endsWith(dotExt)) {
|
||||
return filename;
|
||||
}
|
||||
if (ext === 'jpg' && filename.endsWith('.jpeg')) {
|
||||
return filename;
|
||||
}
|
||||
return `${filename}${dotExt}`;
|
||||
}
|
||||
function contentDisposition(type, filename) {
|
||||
const fallback = filename.replace(/[^\w.-]/g, '_');
|
||||
return _contentDisposition(filename, { type, fallback });
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey-media-proxy",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.14",
|
||||
"description": "The Media Proxy for Misskey",
|
||||
"main": "built/index.js",
|
||||
"packageManager": "pnpm@7.26.0",
|
||||
@ -28,6 +28,7 @@
|
||||
"devDependencies": {
|
||||
"@swc/cli": "^0.1.61",
|
||||
"@swc/core": "^1.3.32",
|
||||
"@types/content-disposition": "^0.5.5",
|
||||
"@types/node": "^18.11.19",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/tmp": "^0.2.3",
|
||||
@ -36,6 +37,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/static": "^6.8.0",
|
||||
"cacheable-lookup": "^7.0.0",
|
||||
"content-disposition": "^0.5.4",
|
||||
"fastify": "^4.12.0",
|
||||
"fastify-cli": "^5.7.1",
|
||||
"file-type": "^18.2.0",
|
||||
|
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -4,10 +4,12 @@ specifiers:
|
||||
'@fastify/static': ^6.8.0
|
||||
'@swc/cli': ^0.1.61
|
||||
'@swc/core': ^1.3.32
|
||||
'@types/content-disposition': ^0.5.5
|
||||
'@types/node': ^18.11.19
|
||||
'@types/sharp': ^0.31.1
|
||||
'@types/tmp': ^0.2.3
|
||||
cacheable-lookup: ^7.0.0
|
||||
content-disposition: ^0.5.4
|
||||
fastify: ^4.12.0
|
||||
fastify-cli: ^5.7.1
|
||||
file-type: ^18.2.0
|
||||
@ -23,6 +25,7 @@ specifiers:
|
||||
dependencies:
|
||||
'@fastify/static': 6.8.0
|
||||
cacheable-lookup: 7.0.0
|
||||
content-disposition: 0.5.4
|
||||
fastify: 4.12.0
|
||||
fastify-cli: 5.7.1
|
||||
file-type: 18.2.0
|
||||
@ -37,6 +40,7 @@ dependencies:
|
||||
devDependencies:
|
||||
'@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/sharp': 0.31.1
|
||||
'@types/tmp': 0.2.3
|
||||
@ -300,6 +304,10 @@ packages:
|
||||
'@types/responselike': 1.0.0
|
||||
dev: true
|
||||
|
||||
/@types/content-disposition/0.5.5:
|
||||
resolution: {integrity: sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==}
|
||||
dev: true
|
||||
|
||||
/@types/http-cache-semantics/4.0.1:
|
||||
resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==}
|
||||
|
||||
|
@ -8,6 +8,7 @@ import IPCIDR from 'ip-cidr';
|
||||
import PrivateIp from 'private-ip';
|
||||
import { StatusError } from './status-error.js';
|
||||
import { getAgents } from './http.js';
|
||||
import { parse } from 'content-disposition';
|
||||
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
|
||||
@ -29,12 +30,17 @@ export const defaultDownloadConfig = {
|
||||
...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} ...`);
|
||||
|
||||
const timeout = 30 * 1000;
|
||||
const operationTimeout = 60 * 1000;
|
||||
|
||||
const urlObj = new URL(url);
|
||||
let filename = urlObj.pathname.split('/').pop() ?? 'unknown';
|
||||
|
||||
const req = got.stream(url, {
|
||||
headers: {
|
||||
'User-Agent': settings.userAgent,
|
||||
@ -73,6 +79,14 @@ export async function downloadUrl(url: string, path: string, settings:DownloadCo
|
||||
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) => {
|
||||
if (progress.transferred > settings.maxSize) {
|
||||
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}`);
|
||||
|
||||
return {
|
||||
filename,
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateIp(ip: string, allowedPrivateNetworks: string[]): boolean {
|
||||
|
39
src/index.ts
39
src/index.ts
@ -13,11 +13,12 @@ import sharp from 'sharp';
|
||||
import { StatusError } from './status-error.js';
|
||||
import { DownloadConfig, defaultDownloadConfig, downloadUrl } from './download.js';
|
||||
import { getAgents } from './http.js';
|
||||
import _contentDisposition from 'content-disposition';
|
||||
|
||||
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;
|
||||
@ -68,11 +69,15 @@ export function setMediaProxyConfig(setting?: MediaProxyOptions | null) {
|
||||
export default function (fastify: FastifyInstance, options: MediaProxyOptions | null | undefined, done: (err?: Error) => void) {
|
||||
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) => {
|
||||
reply.header('Access-Control-Allow-Origin', options!['Access-Control-Allow-Origin'] ?? '*');
|
||||
reply.header('Access-Control-Allow-Headers', options!['Access-Control-Allow-Headers'] ?? '*');
|
||||
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', options!['Content-Security-Policy'] ?? `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`);
|
||||
reply.header('Content-Security-Policy', csp);
|
||||
done();
|
||||
});
|
||||
|
||||
@ -197,6 +202,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) {
|
||||
@ -224,6 +231,7 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; };
|
||||
|
||||
reply.header('Content-Type', image.type);
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
|
||||
return reply.send(image.data);
|
||||
} catch (e) {
|
||||
if ('cleanup' in file) file.cleanup();
|
||||
@ -232,11 +240,11 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; };
|
||||
}
|
||||
|
||||
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();
|
||||
try {
|
||||
await downloadUrl(url, path, config);
|
||||
const { filename } = await downloadUrl(url, path, config);
|
||||
|
||||
const { mime, ext } = await detectType(path);
|
||||
|
||||
@ -244,9 +252,28 @@ async function downloadAndDetectTypeFromUrl(url: string): Promise<
|
||||
state: 'remote',
|
||||
mime, ext,
|
||||
path, cleanup,
|
||||
filename: correctFilename(filename, ext),
|
||||
}
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function correctFilename(filename: string, ext: string | null) {
|
||||
if (!ext) return filename;
|
||||
|
||||
const dotExt = `.${ext}`;
|
||||
if (filename.endsWith(dotExt)) {
|
||||
return filename;
|
||||
}
|
||||
if (ext === 'jpg' && filename.endsWith('.jpeg')) {
|
||||
return filename;
|
||||
}
|
||||
return `${filename}${dotExt}`;
|
||||
}
|
||||
|
||||
function contentDisposition(type: 'inline' | 'attachment', filename: string): string {
|
||||
const fallback = filename.replace(/[^\w.-]/g, '_');
|
||||
return _contentDisposition(filename, { type, fallback });
|
||||
}
|
||||
|
Reference in New Issue
Block a user