diff --git a/README.md b/README.md
index 89abebf..70da8ab 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,25 @@ Misskeyの/proxyが単体で動作します(Misskeyのコードがほぼその
**Fastifyプラグインとして動作する気がします。**
`pnpm start`は[fastify-cli](https://github.com/fastify/fastify-cli)が動作します。
-## セットアップ方法
+## Fastifyプラグインとして動作させる
+### npm install
+
+```
+npm install git+https://github.com/misskey-dev/media-proxy.git
+```
+
+### Fastifyプラグインを書く
+```
+import MediaProxy from 'misskey-media-proxy';
+
+// ......
+
+fastify.register(MediaProxy);
+```
+
+オプションを指定できます。オプションの内容はindex.tsのMediaProxyOptionsに指定してあります。
+
+## サーバーのセットアップ方法
まずはgit cloneしてcdしてください。
```
@@ -25,7 +43,7 @@ NODE_ENV=production pnpm install
```js
import { readFileSync } from 'node:fs';
-const repo = JSON.stringify(readFileSync('./package.json', 'utf8'));
+const repo = JSON.parse(readFileSync('./package.json', 'utf8'));
export default {
// UA
@@ -91,3 +109,4 @@ mediaProxyの指定をdefault.ymlに追記し、Misskeyを再起動してくだ
```yml
mediaProxy: https://mediaproxy.example.com
```
+
diff --git a/built/const.d.ts b/built/const.d.ts
new file mode 100644
index 0000000..68ff82d
--- /dev/null
+++ b/built/const.d.ts
@@ -0,0 +1 @@
+export declare const FILE_TYPE_BROWSERSAFE: string[];
diff --git a/built/const.js b/built/const.js
index 952d3f0..9cdbd5e 100644
--- a/built/const.js
+++ b/built/const.js
@@ -1 +1,40 @@
-export const FILE_TYPE_BROWSERSAFE=["image/png","image/gif","image/jpeg","image/webp","image/avif","image/apng","image/bmp","image/tiff","image/x-icon","audio/opus","video/ogg","audio/ogg","application/ogg","video/quicktime","video/mp4","audio/mp4","video/x-m4v","audio/x-m4a","video/3gpp","video/3gpp2","video/mpeg","audio/mpeg","video/webm","audio/webm","audio/aac","audio/x-flac","audio/vnd.wave"];
\ No newline at end of file
+// ブラウザで直接表示することを許可するファイルの種類のリスト
+// ここに含まれないものは application/octet-stream としてレスポンスされる
+// SVGはXSSを生むので許可しない
+export const FILE_TYPE_BROWSERSAFE = [
+ // Images
+ 'image/png',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/webp',
+ 'image/avif',
+ 'image/apng',
+ 'image/bmp',
+ 'image/tiff',
+ 'image/x-icon',
+ // OggS
+ 'audio/opus',
+ 'video/ogg',
+ 'audio/ogg',
+ 'application/ogg',
+ // ISO/IEC base media file format
+ 'video/quicktime',
+ 'video/mp4',
+ 'audio/mp4',
+ 'video/x-m4v',
+ 'audio/x-m4a',
+ 'video/3gpp',
+ 'video/3gpp2',
+ 'video/mpeg',
+ 'audio/mpeg',
+ 'video/webm',
+ 'audio/webm',
+ 'audio/aac',
+ 'audio/x-flac',
+ 'audio/vnd.wave',
+];
+/*
+https://github.com/sindresorhus/file-type/blob/main/supported.js
+https://github.com/sindresorhus/file-type/blob/main/core.js
+https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
+*/
diff --git a/built/create-temp.d.ts b/built/create-temp.d.ts
new file mode 100644
index 0000000..55102c5
--- /dev/null
+++ b/built/create-temp.d.ts
@@ -0,0 +1,2 @@
+export declare function createTemp(): Promise<[string, () => void]>;
+export declare function createTempDir(): Promise<[string, () => void]>;
diff --git a/built/create-temp.js b/built/create-temp.js
index a517156..f65205f 100644
--- a/built/create-temp.js
+++ b/built/create-temp.js
@@ -1 +1,21 @@
-import*as tmp from"tmp";export function createTemp(){return new Promise((res,rej)=>{tmp.file((e,path,fd,cleanup)=>{if(e)return rej(e);res([path,process.env.NODE_ENV==="production"?cleanup:()=>{}])})})}export function createTempDir(){return new Promise((res,rej)=>{tmp.dir({unsafeCleanup:true},(e,path,cleanup)=>{if(e)return rej(e);res([path,process.env.NODE_ENV==="production"?cleanup:()=>{}])})})}
\ No newline at end of file
+import * as tmp from 'tmp';
+export function createTemp() {
+ return new Promise((res, rej) => {
+ tmp.file((e, path, fd, cleanup) => {
+ if (e)
+ return rej(e);
+ res([path, process.env.NODE_ENV === 'production' ? cleanup : () => { }]);
+ });
+ });
+}
+export function createTempDir() {
+ return new Promise((res, rej) => {
+ tmp.dir({
+ unsafeCleanup: true,
+ }, (e, path, cleanup) => {
+ if (e)
+ return rej(e);
+ res([path, process.env.NODE_ENV === 'production' ? cleanup : () => { }]);
+ });
+ });
+}
diff --git a/built/download.d.ts b/built/download.d.ts
new file mode 100644
index 0000000..7bcd2ca
--- /dev/null
+++ b/built/download.d.ts
@@ -0,0 +1,22 @@
+///
+///
+import * as http from 'node:http';
+import * as https from 'node:https';
+export type DownloadConfig = {
+ [x: string]: any;
+ userAgent: string;
+ allowedPrivateNetworks: string[];
+ maxSize: number;
+ httpAgent: http.Agent;
+ httpsAgent: https.Agent;
+ proxy?: boolean;
+};
+export declare const defaultDownloadConfig: {
+ httpAgent: http.Agent;
+ httpsAgent: https.Agent;
+ userAgent: string;
+ allowedPrivateNetworks: never[];
+ maxSize: number;
+ proxy: boolean;
+};
+export declare function downloadUrl(url: string, path: string, settings?: DownloadConfig): Promise;
diff --git a/built/download.js b/built/download.js
index 4a982c5..9b32599 100644
--- a/built/download.js
+++ b/built/download.js
@@ -1 +1,87 @@
-import*as fs from"node:fs";import*as stream from"node:stream";import*as util from"node:util";import got,*as Got from"got";import IPCIDR from"ip-cidr";import PrivateIp from"private-ip";import{StatusError}from"./status-error.js";import config from"../config.js";import{httpAgent,httpsAgent}from"./http.js";const pipeline=util.promisify(stream.pipeline);export async function downloadUrl(url,path){if(process.env.NODE_ENV!=="production")console.log(`Downloading ${url} to ${path} ...`);const timeout=30*1e3;const operationTimeout=60*1e3;const req=got.stream(url,{headers:{"User-Agent":config.userAgent},timeout:{lookup:timeout,connect:timeout,secureConnect:timeout,socket:timeout,response:timeout,send:timeout,request:operationTimeout},agent:{http:httpAgent,https:httpsAgent},http2:true,retry:{limit:0},enableUnixSockets:false}).on("response",res=>{if((process.env.NODE_ENV==="production"||process.env.NODE_ENV==="test")&&!config.proxy&&res.ip){if(isPrivateIp(res.ip)){console.log(`Blocked address: ${res.ip}`);req.destroy()}}const contentLength=res.headers["content-length"];if(contentLength!=null){const size=Number(contentLength);if(size>config.maxSize){console.log(`maxSize exceeded (${size} > ${config.maxSize}) on response`);req.destroy()}}}).on("downloadProgress",progress=>{if(progress.transferred>config.maxSize){console.log(`maxSize exceeded (${progress.transferred} > ${config.maxSize}) on downloadProgress`);req.destroy()}});try{await pipeline(req,fs.createWriteStream(path))}catch(e){if(e instanceof Got.HTTPError){throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`,e.response.statusCode,e.response.statusMessage)}else{throw e}}if(process.env.NODE_ENV!=="production")console.log(`Download finished: ${url}`)}function isPrivateIp(ip){for(const net of config.allowedPrivateNetworks??[]){const cidr=new IPCIDR(net);if(cidr.contains(ip)){return false}}return PrivateIp(ip)??false}
\ No newline at end of file
+import * as fs from 'node:fs';
+import * as stream from 'node:stream';
+import * as util from 'node:util';
+import got, * as Got from 'got';
+import IPCIDR from 'ip-cidr';
+import PrivateIp from 'private-ip';
+import { StatusError } from './status-error.js';
+import { getAgents } from './http.js';
+const pipeline = util.promisify(stream.pipeline);
+export const defaultDownloadConfig = {
+ userAgent: `MisskeyMediaProxy/0.0.0`,
+ allowedPrivateNetworks: [],
+ maxSize: 262144000,
+ proxy: false,
+ ...getAgents()
+};
+export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
+ if (process.env.NODE_ENV !== 'production')
+ console.log(`Downloading ${url} to ${path} ...`);
+ const timeout = 30 * 1000;
+ const operationTimeout = 60 * 1000;
+ const req = got.stream(url, {
+ headers: {
+ 'User-Agent': settings.userAgent,
+ },
+ timeout: {
+ lookup: timeout,
+ connect: timeout,
+ secureConnect: timeout,
+ socket: timeout,
+ response: timeout,
+ send: timeout,
+ request: operationTimeout, // whole operation timeout
+ },
+ agent: {
+ http: settings.httpAgent,
+ https: settings.httpsAgent,
+ },
+ http2: true,
+ retry: {
+ limit: 0,
+ },
+ enableUnixSockets: false,
+ }).on('response', (res) => {
+ if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !settings.proxy && res.ip) {
+ if (isPrivateIp(res.ip, settings.allowedPrivateNetworks)) {
+ console.log(`Blocked address: ${res.ip}`);
+ req.destroy();
+ }
+ }
+ const contentLength = res.headers['content-length'];
+ if (contentLength != null) {
+ const size = Number(contentLength);
+ if (size > settings.maxSize) {
+ console.log(`maxSize exceeded (${size} > ${settings.maxSize}) on response`);
+ req.destroy();
+ }
+ }
+ }).on('downloadProgress', (progress) => {
+ if (progress.transferred > settings.maxSize) {
+ console.log(`maxSize exceeded (${progress.transferred} > ${settings.maxSize}) on downloadProgress`);
+ req.destroy();
+ }
+ });
+ try {
+ await pipeline(req, fs.createWriteStream(path));
+ }
+ catch (e) {
+ if (e instanceof Got.HTTPError) {
+ throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage);
+ }
+ else {
+ throw e;
+ }
+ }
+ if (process.env.NODE_ENV !== 'production')
+ console.log(`Download finished: ${url}`);
+}
+function isPrivateIp(ip, allowedPrivateNetworks) {
+ for (const net of allowedPrivateNetworks ?? []) {
+ const cidr = new IPCIDR(net);
+ if (cidr.contains(ip)) {
+ return false;
+ }
+ }
+ return PrivateIp(ip) ?? false;
+}
diff --git a/built/file-info.d.ts b/built/file-info.d.ts
new file mode 100644
index 0000000..76fc22b
--- /dev/null
+++ b/built/file-info.d.ts
@@ -0,0 +1,11 @@
+export declare function detectType(path: string): Promise<{
+ mime: string;
+ ext: string | null;
+}>;
+declare const dictionary: {
+ 'safe-file': string[];
+ 'sharp-convertible-image': string[];
+ 'sharp-animation-convertible-image': string[];
+};
+export declare const isMimeImage: (mime: string, type: keyof typeof dictionary) => boolean;
+export {};
diff --git a/built/file-info.js b/built/file-info.js
index a9dd393..a04c39b 100644
--- a/built/file-info.js
+++ b/built/file-info.js
@@ -1 +1,58 @@
-import fs from"node:fs";import{fileTypeFromFile}from"file-type";import isSvg from"is-svg";import{promisify}from"node:util";const TYPE_OCTET_STREAM={mime:"application/octet-stream",ext:null};const TYPE_SVG={mime:"image/svg+xml",ext:"svg"};async function getFileSize(path){const getStat=promisify(fs.stat);return(await getStat(path)).size}export async function detectType(path){const fileSize=await getFileSize(path);if(fileSize===0){return TYPE_OCTET_STREAM}const type=await fileTypeFromFile(path);if(type){if(type.mime==="application/xml"&&await checkSvg(path)){return TYPE_SVG}return{mime:type.mime,ext:type.ext}}if(await checkSvg(path)){return TYPE_SVG}return TYPE_OCTET_STREAM}async function checkSvg(path){try{const size=await getFileSize(path);if(size>1*1024*1024)return false;return isSvg(fs.readFileSync(path))}catch{return false}}import{FILE_TYPE_BROWSERSAFE}from"./const.js";const dictionary={"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-animation-convertible-image":["image/jpeg","image/png","image/gif","image/webp","image/avif","image/svg+xml"]};export const isMimeImage=(mime,type)=>dictionary[type].includes(mime);
\ No newline at end of file
+import fs from 'node:fs';
+import { fileTypeFromFile } from 'file-type';
+import isSvg from 'is-svg';
+import { promisify } from 'node:util';
+const TYPE_OCTET_STREAM = {
+ mime: 'application/octet-stream',
+ ext: null,
+};
+const TYPE_SVG = {
+ mime: 'image/svg+xml',
+ ext: 'svg',
+};
+async function getFileSize(path) {
+ const getStat = promisify(fs.stat);
+ return (await getStat(path)).size;
+}
+export async function detectType(path) {
+ // Check 0 byte
+ const fileSize = await getFileSize(path);
+ if (fileSize === 0) {
+ return TYPE_OCTET_STREAM;
+ }
+ const type = await fileTypeFromFile(path);
+ if (type) {
+ // XMLはSVGかもしれない
+ if (type.mime === 'application/xml' && await checkSvg(path)) {
+ return TYPE_SVG;
+ }
+ return {
+ mime: type.mime,
+ ext: type.ext,
+ };
+ }
+ // 種類が不明でもSVGかもしれない
+ if (await checkSvg(path)) {
+ return TYPE_SVG;
+ }
+ // それでも種類が不明なら application/octet-stream にする
+ return TYPE_OCTET_STREAM;
+}
+async function checkSvg(path) {
+ try {
+ const size = await getFileSize(path);
+ if (size > 1 * 1024 * 1024)
+ return false;
+ return isSvg(fs.readFileSync(path));
+ }
+ catch {
+ return false;
+ }
+}
+import { FILE_TYPE_BROWSERSAFE } from './const.js';
+const dictionary = {
+ '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-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'],
+};
+export const isMimeImage = (mime, type) => dictionary[type].includes(mime);
diff --git a/built/http.d.ts b/built/http.d.ts
new file mode 100644
index 0000000..fa2402f
--- /dev/null
+++ b/built/http.d.ts
@@ -0,0 +1,8 @@
+///
+///
+import * as http from 'node:http';
+import * as https from 'node:https';
+export declare function getAgents(proxy?: string): {
+ httpAgent: http.Agent;
+ httpsAgent: https.Agent;
+};
diff --git a/built/http.js b/built/http.js
index 1c4189f..e9ff3ae 100644
--- a/built/http.js
+++ b/built/http.js
@@ -1 +1,45 @@
-import*as http from"node:http";import*as https from"node:https";import CacheableLookup from"cacheable-lookup";import{HttpProxyAgent,HttpsProxyAgent}from"hpagent";import config from"../config.js";const cache=new CacheableLookup({maxTtl:3600,errorTtl:30,lookup:false});const _http=new http.Agent({keepAlive:true,keepAliveMsecs:30*1e3,lookup:cache.lookup});const _https=new https.Agent({keepAlive:true,keepAliveMsecs:30*1e3,lookup:cache.lookup});export const httpAgent=config.proxy?new HttpProxyAgent({keepAlive:true,keepAliveMsecs:30*1e3,maxSockets:256,maxFreeSockets:256,scheduling:"lifo",proxy:config.proxy}):_http;export const httpsAgent=config.proxy?new HttpsProxyAgent({keepAlive:true,keepAliveMsecs:30*1e3,maxSockets:256,maxFreeSockets:256,scheduling:"lifo",proxy:config.proxy}):_https;
\ No newline at end of file
+import * as http from 'node:http';
+import * as https from 'node:https';
+import CacheableLookup from 'cacheable-lookup';
+import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
+const cache = new CacheableLookup({
+ maxTtl: 3600,
+ errorTtl: 30,
+ lookup: false, // nativeのdns.lookupにfallbackしない
+});
+const _http = new http.Agent({
+ keepAlive: true,
+ keepAliveMsecs: 30 * 1000,
+ lookup: cache.lookup,
+});
+const _https = new https.Agent({
+ keepAlive: true,
+ keepAliveMsecs: 30 * 1000,
+ lookup: cache.lookup,
+});
+export function getAgents(proxy) {
+ const httpAgent = proxy
+ ? new HttpProxyAgent({
+ keepAlive: true,
+ keepAliveMsecs: 30 * 1000,
+ maxSockets: 256,
+ maxFreeSockets: 256,
+ scheduling: 'lifo',
+ proxy: proxy,
+ })
+ : _http;
+ const httpsAgent = proxy
+ ? new HttpsProxyAgent({
+ keepAlive: true,
+ keepAliveMsecs: 30 * 1000,
+ maxSockets: 256,
+ maxFreeSockets: 256,
+ scheduling: 'lifo',
+ proxy: proxy,
+ })
+ : _https;
+ return {
+ httpAgent,
+ httpsAgent,
+ };
+}
diff --git a/built/image-processor.d.ts b/built/image-processor.d.ts
new file mode 100644
index 0000000..717e6b8
--- /dev/null
+++ b/built/image-processor.d.ts
@@ -0,0 +1,18 @@
+///
+///
+import sharp from 'sharp';
+import { Readable } from 'node:stream';
+export type IImage = {
+ data: Buffer;
+ ext: string | null;
+ type: string;
+};
+export type IImageStream = {
+ data: Readable;
+ ext: string | null;
+ type: string;
+};
+export type IImageStreamable = IImage | IImageStream;
+export declare const webpDefault: sharp.WebpOptions;
+export declare function convertToWebpStream(path: string, width: number, height: number, options?: sharp.WebpOptions): IImageStream;
+export declare function convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options?: sharp.WebpOptions): IImageStream;
diff --git a/built/image-processor.js b/built/image-processor.js
index a62f340..be63182 100644
--- a/built/image-processor.js
+++ b/built/image-processor.js
@@ -1 +1,26 @@
-import sharp from"sharp";export const webpDefault={quality:85,alphaQuality:95,lossless:false,nearLossless:false,smartSubsample:true,mixed:true};export function convertToWebpStream(path,width,height,options=webpDefault){return convertSharpToWebpStream(sharp(path),width,height,options)}export function convertSharpToWebpStream(sharp,width,height,options=webpDefault){const data=sharp.resize(width,height,{fit:"inside",withoutEnlargement:true}).rotate().webp(options);return{data,ext:"webp",type:"image/webp"}}
\ No newline at end of file
+import sharp from 'sharp';
+export const webpDefault = {
+ quality: 85,
+ alphaQuality: 95,
+ lossless: false,
+ nearLossless: false,
+ smartSubsample: true,
+ mixed: true,
+};
+export function convertToWebpStream(path, width, height, options = webpDefault) {
+ return convertSharpToWebpStream(sharp(path), width, height, options);
+}
+export function convertSharpToWebpStream(sharp, width, height, options = webpDefault) {
+ const data = sharp
+ .resize(width, height, {
+ fit: 'inside',
+ withoutEnlargement: true,
+ })
+ .rotate()
+ .webp(options);
+ return {
+ data,
+ ext: 'webp',
+ type: 'image/webp',
+ };
+}
diff --git a/built/index.d.ts b/built/index.d.ts
new file mode 100644
index 0000000..9032e39
--- /dev/null
+++ b/built/index.d.ts
@@ -0,0 +1,17 @@
+///
+///
+import * as http from 'node:http';
+import * as https from 'node:https';
+import type { FastifyInstance } from 'fastify';
+export type MediaProxyOptions = {
+ userAgent?: string;
+ allowedPrivateNetworks?: string[];
+ maxSize?: number;
+} & ({
+ proxy?: string;
+} | {
+ httpAgent: http.Agent;
+ httpsAgent: https.Agent;
+});
+export declare function setMediaProxyConfig(setting?: MediaProxyOptions | null): void;
+export default function (fastify: FastifyInstance, options: MediaProxyOptions | null | undefined, done: (err?: Error) => void): void;
diff --git a/built/index.js b/built/index.js
index 95fdfa2..54744f9 100644
--- a/built/index.js
+++ b/built/index.js
@@ -1 +1,192 @@
-import*as fs from"node:fs";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{downloadUrl}from"./download.js";const _filename=fileURLToPath(import.meta.url);const _dirname=dirname(_filename);const assets=`${_dirname}/../../server/file/assets/`;export default function(fastify,options,done){fastify.addHook("onRequest",(request,reply,done)=>{reply.header("Content-Security-Policy",`default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`);done()});fastify.register(fastifyStatic,{root:_dirname,serve:false});fastify.get("/:url*",async(request,reply)=>{return await proxyHandler(request,reply).catch(err=>errorHandler(request,reply,err))});done()}function errorHandler(request,reply,err){console.log(`${err}`);reply.header("Cache-Control","max-age=300");if(request.query&&"fallback"in request.query){return reply.sendFile("/dummy.png",assets)}if(err instanceof StatusError&&(err.statusCode===302||err.isClientError)){reply.code(err.statusCode);return}reply.code(500);return}async function proxyHandler(request,reply){const url="url"in request.query?request.query.url:"https://"+request.params.url;if(typeof url!=="string"){reply.code(400);return}const file=await downloadAndDetectTypeFromUrl(url);try{const isConvertibleImage=isMimeImage(file.mime,"sharp-convertible-image");const isAnimationConvertibleImage=isMimeImage(file.mime,"sharp-animation-convertible-image");let image=null;if(("emoji"in request.query||"avatar"in request.query)&&isConvertibleImage){if(!isAnimationConvertibleImage&&!("static"in request.query)){image={data:fs.createReadStream(file.path),ext:file.ext,type:file.mime}}else{const data=sharp(file.path,{animated:!("static"in request.query)}).resize({height:"emoji"in request.query?128:320,withoutEnlargement:true}).webp(webpDefault);image={data,ext:"webp",type:"image/webp"}}}else if("static"in request.query&&isConvertibleImage){image=convertToWebpStream(file.path,498,280)}else if("preview"in request.query&&isConvertibleImage){image=convertToWebpStream(file.path,200,200)}else if("badge"in request.query){if(!isConvertibleImage){throw new StatusError("Unexpected mime",404)}const mask=sharp(file.path).resize(96,96,{fit:"inside",withoutEnlargement:false}).greyscale().normalise().linear(1.75,-128*1.75+128).flatten({background:"#000"}).toColorspace("b-w");const stats=await mask.clone().stats();if(stats.entropy<.1){throw new StatusError("Skip to provide badge",404)}const data=sharp({create:{width:96,height:96,channels:4,background:{r:0,g:0,b:0,alpha:0}}}).pipelineColorspace("b-w").boolean(await mask.png().toBuffer(),"eor");image={data:await data.png().toBuffer(),ext:"png",type:"image/png"}}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),ext:file.ext,type:file.mime}}if("cleanup"in file){if("pipe"in image.data&&typeof image.data.pipe==="function"){image.data.on("end",file.cleanup);image.data.on("close",file.cleanup)}else{file.cleanup()}}reply.header("Content-Type",image.type);reply.header("Cache-Control","max-age=31536000, immutable");return image.data}catch(e){if("cleanup"in file)file.cleanup();throw e}}async function downloadAndDetectTypeFromUrl(url){const[path,cleanup]=await createTemp();try{await downloadUrl(url,path);const{mime,ext}=await detectType(path);return{state:"remote",mime,ext,path,cleanup}}catch(e){cleanup();throw e}}
\ No newline at end of file
+import * as fs from 'node:fs';
+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';
+const _filename = fileURLToPath(import.meta.url);
+const _dirname = dirname(_filename);
+const assets = `${_dirname}/../../server/file/assets/`;
+let config = defaultDownloadConfig;
+export function setMediaProxyConfig(setting) {
+ const proxy = process.env.HTTP_PROXY ?? process.env.http_proxy;
+ if (!setting) {
+ config = {
+ ...defaultDownloadConfig,
+ ...(proxy ? getAgents(proxy) : {}),
+ proxy: !!proxy,
+ };
+ console.log(config);
+ return;
+ }
+ config = {
+ userAgent: setting.userAgent ?? defaultDownloadConfig.userAgent,
+ allowedPrivateNetworks: setting.allowedPrivateNetworks ?? defaultDownloadConfig.allowedPrivateNetworks,
+ maxSize: setting.maxSize ?? defaultDownloadConfig.maxSize,
+ ...('proxy' in setting ?
+ { ...getAgents(setting.proxy), proxy: !!setting.proxy } :
+ 'httpAgent' in setting ? {
+ httpAgent: setting.httpAgent,
+ httpsAgent: setting.httpsAgent,
+ proxy: true,
+ } :
+ { ...getAgents(proxy), proxy: !!proxy }),
+ };
+ console.log(config);
+}
+export default function (fastify, options, done) {
+ setMediaProxyConfig(options);
+ fastify.addHook('onRequest', (request, reply, done) => {
+ reply.header('Content-Security-Policy', `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`);
+ done();
+ });
+ fastify.register(fastifyStatic, {
+ root: _dirname,
+ serve: false,
+ });
+ fastify.get('/:url*', async (request, reply) => {
+ return await proxyHandler(request, reply)
+ .catch(err => errorHandler(request, reply, err));
+ });
+ done();
+}
+function errorHandler(request, reply, err) {
+ console.log(`${err}`);
+ reply.header('Cache-Control', 'max-age=300');
+ if (request.query && 'fallback' in request.query) {
+ return reply.sendFile('/dummy.png', assets);
+ }
+ if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) {
+ reply.code(err.statusCode);
+ return;
+ }
+ reply.code(500);
+ return;
+}
+async function proxyHandler(request, reply) {
+ const url = 'url' in request.query ? request.query.url : (request.params.url && 'https://' + request.params.url);
+ if (!url || typeof url !== 'string') {
+ reply.code(400);
+ return;
+ }
+ // Create temp file
+ const file = await downloadAndDetectTypeFromUrl(url);
+ try {
+ const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image');
+ const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image');
+ let image = null;
+ if (('emoji' in request.query || 'avatar' in request.query) && isConvertibleImage) {
+ if (!isAnimationConvertibleImage && !('static' in request.query)) {
+ image = {
+ data: fs.createReadStream(file.path),
+ ext: file.ext,
+ type: file.mime,
+ };
+ }
+ else {
+ const data = sharp(file.path, { animated: !('static' in request.query) })
+ .resize({
+ height: 'emoji' in request.query ? 128 : 320,
+ withoutEnlargement: true,
+ })
+ .webp(webpDefault);
+ image = {
+ data,
+ ext: 'webp',
+ type: 'image/webp',
+ };
+ }
+ }
+ else if ('static' in request.query && isConvertibleImage) {
+ image = convertToWebpStream(file.path, 498, 280);
+ }
+ else if ('preview' in request.query && isConvertibleImage) {
+ image = convertToWebpStream(file.path, 200, 200);
+ }
+ else if ('badge' in request.query) {
+ if (!isConvertibleImage) {
+ // 画像でないなら404でお茶を濁す
+ throw new StatusError('Unexpected mime', 404);
+ }
+ const mask = sharp(file.path)
+ .resize(96, 96, {
+ fit: 'inside',
+ withoutEnlargement: false,
+ })
+ .greyscale()
+ .normalise()
+ .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast
+ .flatten({ background: '#000' })
+ .toColorspace('b-w');
+ const stats = await mask.clone().stats();
+ if (stats.entropy < 0.1) {
+ // エントロピーがあまりない場合は404にする
+ throw new StatusError('Skip to provide badge', 404);
+ }
+ const data = sharp({
+ create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
+ })
+ .pipelineColorspace('b-w')
+ .boolean(await mask.png().toBuffer(), 'eor');
+ image = {
+ data: await data.png().toBuffer(),
+ ext: 'png',
+ type: 'image/png',
+ };
+ }
+ 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),
+ ext: file.ext,
+ type: file.mime,
+ };
+ }
+ if ('cleanup' in file) {
+ if ('pipe' in image.data && typeof image.data.pipe === 'function') {
+ // image.dataがstreamなら、stream終了後にcleanup
+ image.data.on('end', file.cleanup);
+ image.data.on('close', file.cleanup);
+ }
+ else {
+ // image.dataがstreamでないなら直ちにcleanup
+ file.cleanup();
+ }
+ }
+ reply.header('Content-Type', image.type);
+ reply.header('Cache-Control', 'max-age=31536000, immutable');
+ return image.data;
+ }
+ catch (e) {
+ if ('cleanup' in file)
+ file.cleanup();
+ throw e;
+ }
+}
+async function downloadAndDetectTypeFromUrl(url) {
+ const [path, cleanup] = await createTemp();
+ try {
+ await downloadUrl(url, path, config);
+ const { mime, ext } = await detectType(path);
+ return {
+ state: 'remote',
+ mime, ext,
+ path, cleanup,
+ };
+ }
+ catch (e) {
+ cleanup();
+ throw e;
+ }
+}
diff --git a/built/status-error.d.ts b/built/status-error.d.ts
new file mode 100644
index 0000000..0f3f195
--- /dev/null
+++ b/built/status-error.d.ts
@@ -0,0 +1,6 @@
+export declare class StatusError extends Error {
+ statusCode: number;
+ statusMessage?: string;
+ isClientError: boolean;
+ constructor(message: string, statusCode: number, statusMessage?: string);
+}
diff --git a/built/status-error.js b/built/status-error.js
index 7a0e647..348e1a4 100644
--- a/built/status-error.js
+++ b/built/status-error.js
@@ -1 +1,9 @@
-export class StatusError extends Error{constructor(message,statusCode,statusMessage){super(message);this.name="StatusError";this.statusCode=statusCode;this.statusMessage=statusMessage;this.isClientError=typeof this.statusCode==="number"&&this.statusCode>=400&&this.statusCode<500}}
\ No newline at end of file
+export class StatusError extends Error {
+ constructor(message, statusCode, statusMessage) {
+ super(message);
+ this.name = 'StatusError';
+ this.statusCode = statusCode;
+ this.statusMessage = statusMessage;
+ this.isClientError = typeof this.statusCode === 'number' && this.statusCode >= 400 && this.statusCode < 500;
+ }
+}
diff --git a/package.json b/package.json
index 33a8086..ac8492b 100644
--- a/package.json
+++ b/package.json
@@ -6,9 +6,9 @@
"packageManager": "pnpm@7.26.0",
"type": "module",
"scripts": {
- "build": "swc src -d built -D",
+ "build": "tsc -p tsconfig.json",
"watch": "swc src -d built -D -w & fastify start -w -l info -P ./built/index.js",
- "start": "fastify start ./built/index.js"
+ "start": "fastify start ./server.js"
},
"repository": {
"type": "git",
@@ -23,8 +23,10 @@
"devDependencies": {
"@swc/cli": "^0.1.61",
"@swc/core": "^1.3.32",
+ "@types/node": "^18.11.19",
"@types/sharp": "^0.31.1",
- "@types/tmp": "^0.2.3"
+ "@types/tmp": "^0.2.3",
+ "typescript": "^4.9.5"
},
"dependencies": {
"@fastify/static": "^6.8.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8e104f3..1f38951 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,6 +4,7 @@ specifiers:
'@fastify/static': ^6.8.0
'@swc/cli': ^0.1.61
'@swc/core': ^1.3.32
+ '@types/node': ^18.11.19
'@types/sharp': ^0.31.1
'@types/tmp': ^0.2.3
cacheable-lookup: ^7.0.0
@@ -17,6 +18,7 @@ specifiers:
private-ip: ^3.0.0
sharp: ^0.31.3
tmp: ^0.2.1
+ typescript: ^4.9.5
dependencies:
'@fastify/static': 6.8.0
@@ -35,8 +37,10 @@ dependencies:
devDependencies:
'@swc/cli': 0.1.61_@swc+core@1.3.32
'@swc/core': 1.3.32
+ '@types/node': 18.11.19
'@types/sharp': 0.31.1
'@types/tmp': 0.2.3
+ typescript: 4.9.5
packages:
@@ -47,6 +51,7 @@ packages:
/@fastify/accept-negotiator/1.1.0:
resolution: {integrity: sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==}
engines: {node: '>=14'}
+ dev: false
/@fastify/ajv-compiler/3.5.0:
resolution: {integrity: sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA==}
@@ -78,6 +83,7 @@ packages:
fast-decode-uri-component: 1.0.1
http-errors: 2.0.0
mime: 3.0.0
+ dev: false
/@fastify/static/6.8.0:
resolution: {integrity: sha512-MNQp7KM0NIC+722OPN3MholnfvM+Vg2ao4OwbWWNJhAJEWOKGe4fJsEjIh3OkN0z5ymhklc7EXGCG0zDaIU5ZQ==}
@@ -94,6 +100,7 @@ packages:
/@lukeed/ms/2.0.1:
resolution: {integrity: sha512-Xs/4RZltsAL7pkvaNStUQt7netTkyxrS0K+RILcVr3TRMS/ToOg4I6uNfhB9SlGsnWBym4U+EaXq0f0cEMNkHA==}
engines: {node: '>=8'}
+ dev: false
/@mole-inc/bin-wrapper/8.0.1:
resolution: {integrity: sha512-sTGoeZnjI8N4KS+sW2AN95gDBErhAguvkw/tWdCjeM8bvxpz5lqrnd0vOJABA1A+Ic3zED7PYoLP/RANLgVotA==}
@@ -289,7 +296,7 @@ packages:
dependencies:
'@types/http-cache-semantics': 4.0.1
'@types/keyv': 3.1.4
- '@types/node': 18.11.18
+ '@types/node': 18.11.19
'@types/responselike': 1.0.0
dev: true
@@ -299,23 +306,23 @@ packages:
/@types/keyv/3.1.4:
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
dependencies:
- '@types/node': 18.11.18
+ '@types/node': 18.11.19
dev: true
- /@types/node/18.11.18:
- resolution: {integrity: sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==}
+ /@types/node/18.11.19:
+ resolution: {integrity: sha512-YUgMWAQBWLObABqrvx8qKO1enAvBUdjZOAWQ5grBAkp5LQv45jBvYKZ3oFS9iKRCQyFjqw6iuEa1vmFqtxYLZw==}
dev: true
/@types/responselike/1.0.0:
resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==}
dependencies:
- '@types/node': 18.11.18
+ '@types/node': 18.11.19
dev: true
/@types/sharp/0.31.1:
resolution: {integrity: sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==}
dependencies:
- '@types/node': 18.11.18
+ '@types/node': 18.11.19
dev: true
/@types/tmp/0.2.3:
@@ -646,6 +653,7 @@ packages:
/depd/2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
+ dev: false
/detect-libc/2.0.1:
resolution: {integrity: sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==}
@@ -664,6 +672,7 @@ packages:
/escape-html/1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+ dev: false
/escape-string-regexp/5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
@@ -1063,6 +1072,7 @@ packages:
setprototypeof: 1.2.0
statuses: 2.0.1
toidentifier: 1.0.1
+ dev: false
/http2-wrapper/1.0.3:
resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==}
@@ -1291,6 +1301,7 @@ packages:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
engines: {node: '>=10.0.0'}
hasBin: true
+ dev: false
/mimic-fn/2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
@@ -1435,6 +1446,7 @@ packages:
engines: {node: '>=10'}
dependencies:
yocto-queue: 0.1.0
+ dev: false
/p-locate/3.0.0:
resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
@@ -1755,6 +1767,7 @@ packages:
/setprototypeof/1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+ dev: false
/sharp/0.31.3:
resolution: {integrity: sha512-XcR4+FCLBFKw1bdB+GEhnUNXNXvnt0tDo4WsBsraKymuo/IAuPuCBVAL2wIkUw2r/dwFW5Q5+g66Kwl2dgDFVg==}
@@ -1865,6 +1878,7 @@ packages:
/statuses/2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
+ dev: false
/string_decoder/1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -1965,6 +1979,7 @@ packages:
/toidentifier/1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
+ dev: false
/token-types/5.0.1:
resolution: {integrity: sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==}
@@ -1986,6 +2001,12 @@ packages:
safe-buffer: 5.2.1
dev: false
+ /typescript/4.9.5:
+ resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==}
+ engines: {node: '>=4.2.0'}
+ hasBin: true
+ dev: true
+
/uri-js/4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
dependencies:
@@ -2034,3 +2055,4 @@ packages:
/yocto-queue/0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ dev: false
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..813feea
--- /dev/null
+++ b/server.js
@@ -0,0 +1,6 @@
+import config from './config.js';
+import app from './built/index.js';
+
+export default function (fastify, opts, next) {
+ return app(fastify, { ...config, ...opts }, next);
+}
diff --git a/src/download.ts b/src/download.ts
index 04d6995..4401aa6 100644
--- a/src/download.ts
+++ b/src/download.ts
@@ -1,16 +1,35 @@
import * as fs from 'node:fs';
import * as stream from 'node:stream';
import * as util from 'node:util';
+import * as http from 'node:http';
+import * as https from 'node:https';
import got, * as Got from 'got';
import IPCIDR from 'ip-cidr';
import PrivateIp from 'private-ip';
import { StatusError } from './status-error.js';
-import config from '../config.js';
-import { httpAgent, httpsAgent } from './http.js';
+import { getAgents } from './http.js';
const pipeline = util.promisify(stream.pipeline);
-export async function downloadUrl(url: string, path: string): Promise {
+export type DownloadConfig = {
+ [x: string]: any;
+ userAgent: string;
+ allowedPrivateNetworks: string[];
+ maxSize: number;
+ httpAgent: http.Agent,
+ httpsAgent: https.Agent,
+ proxy?: boolean;
+}
+
+export const defaultDownloadConfig = {
+ userAgent: `MisskeyMediaProxy/0.0.0`,
+ allowedPrivateNetworks: [],
+ maxSize: 262144000,
+ proxy: false,
+ ...getAgents()
+}
+
+export async function downloadUrl(url: string, path: string, settings:DownloadConfig = defaultDownloadConfig): Promise {
if (process.env.NODE_ENV !== 'production') console.log(`Downloading ${url} to ${path} ...`);
const timeout = 30 * 1000;
@@ -18,7 +37,7 @@ export async function downloadUrl(url: string, path: string): Promise {
const req = got.stream(url, {
headers: {
- 'User-Agent': config.userAgent,
+ 'User-Agent': settings.userAgent,
},
timeout: {
lookup: timeout,
@@ -30,8 +49,8 @@ export async function downloadUrl(url: string, path: string): Promise {
request: operationTimeout, // whole operation timeout
},
agent: {
- http: httpAgent,
- https: httpsAgent,
+ http: settings.httpAgent,
+ https: settings.httpsAgent,
},
http2: true,
retry: {
@@ -39,8 +58,8 @@ export async function downloadUrl(url: string, path: string): Promise {
},
enableUnixSockets: false,
}).on('response', (res: Got.Response) => {
- if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !config.proxy && res.ip) {
- if (isPrivateIp(res.ip)) {
+ if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !settings.proxy && res.ip) {
+ if (isPrivateIp(res.ip, settings.allowedPrivateNetworks)) {
console.log(`Blocked address: ${res.ip}`);
req.destroy();
}
@@ -49,14 +68,14 @@ export async function downloadUrl(url: string, path: string): Promise {
const contentLength = res.headers['content-length'];
if (contentLength != null) {
const size = Number(contentLength);
- if (size > config.maxSize) {
- console.log(`maxSize exceeded (${size} > ${config.maxSize}) on response`);
+ if (size > settings.maxSize) {
+ console.log(`maxSize exceeded (${size} > ${settings.maxSize}) on response`);
req.destroy();
}
}
}).on('downloadProgress', (progress: Got.Progress) => {
- if (progress.transferred > config.maxSize) {
- console.log(`maxSize exceeded (${progress.transferred} > ${config.maxSize}) on downloadProgress`);
+ if (progress.transferred > settings.maxSize) {
+ console.log(`maxSize exceeded (${progress.transferred} > ${settings.maxSize}) on downloadProgress`);
req.destroy();
}
});
@@ -75,8 +94,8 @@ export async function downloadUrl(url: string, path: string): Promise {
}
-function isPrivateIp(ip: string): boolean {
- for (const net of config.allowedPrivateNetworks ?? []) {
+function isPrivateIp(ip: string, allowedPrivateNetworks: string[]): boolean {
+ for (const net of allowedPrivateNetworks ?? []) {
const cidr = new IPCIDR(net);
if (cidr.contains(ip)) {
return false;
diff --git a/src/http.ts b/src/http.ts
index 40f7e7b..c8c45cd 100644
--- a/src/http.ts
+++ b/src/http.ts
@@ -2,7 +2,6 @@ import * as http from 'node:http';
import * as https from 'node:https';
import CacheableLookup from 'cacheable-lookup';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
-import config from "../config.js";
const cache = new CacheableLookup({
maxTtl: 3600, // 1hours
@@ -22,24 +21,31 @@ const _https = new https.Agent({
lookup: cache.lookup,
} as https.AgentOptions);
-export const httpAgent = config.proxy
- ? new HttpProxyAgent({
- keepAlive: true,
- keepAliveMsecs: 30 * 1000,
- maxSockets: 256,
- maxFreeSockets: 256,
- scheduling: 'lifo',
- proxy: config.proxy,
- })
- : _http;
+export function getAgents(proxy?: string) {
+ const httpAgent = proxy
+ ? new HttpProxyAgent({
+ keepAlive: true,
+ keepAliveMsecs: 30 * 1000,
+ maxSockets: 256,
+ maxFreeSockets: 256,
+ scheduling: 'lifo',
+ proxy: proxy,
+ })
+ : _http;
-export const httpsAgent = config.proxy
- ? new HttpsProxyAgent({
- keepAlive: true,
- keepAliveMsecs: 30 * 1000,
- maxSockets: 256,
- maxFreeSockets: 256,
- scheduling: 'lifo',
- proxy: config.proxy,
- })
- : _https;
\ No newline at end of file
+ const httpsAgent = proxy
+ ? new HttpsProxyAgent({
+ keepAlive: true,
+ keepAliveMsecs: 30 * 1000,
+ maxSockets: 256,
+ maxFreeSockets: 256,
+ scheduling: 'lifo',
+ proxy: proxy,
+ })
+ : _https;
+
+ return {
+ httpAgent,
+ httpsAgent,
+ };
+}
diff --git a/src/index.ts b/src/index.ts
index 1c116f7..96d8c49 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,4 +1,6 @@
import * as fs from 'node:fs';
+import * as http from 'node:http';
+import * as https from 'node:https';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import fastifyStatic from '@fastify/static';
@@ -9,14 +11,60 @@ import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOption
import { detectType, isMimeImage } from './file-info.js';
import sharp from 'sharp';
import { StatusError } from './status-error.js';
-import { downloadUrl } from './download.js';
+import { DownloadConfig, 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/`;
-export default function (fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
+export type MediaProxyOptions = {
+ userAgent?: string;
+ allowedPrivateNetworks?: string[];
+ maxSize?: number;
+} & ({
+ proxy?: string;
+} | {
+ httpAgent: http.Agent;
+ httpsAgent: https.Agent;
+});
+
+let config: DownloadConfig = defaultDownloadConfig;
+
+export function setMediaProxyConfig(setting?: MediaProxyOptions | null) {
+ const proxy = process.env.HTTP_PROXY ?? process.env.http_proxy;
+
+ if (!setting) {
+ config = {
+ ...defaultDownloadConfig,
+ ...(proxy ? getAgents(proxy) : {}),
+ proxy: !!proxy,
+ };
+ console.log(config);
+ return;
+ }
+
+ config = {
+ userAgent: setting.userAgent ?? defaultDownloadConfig.userAgent,
+ allowedPrivateNetworks: setting.allowedPrivateNetworks ?? defaultDownloadConfig.allowedPrivateNetworks,
+ maxSize: setting.maxSize ?? defaultDownloadConfig.maxSize,
+ ...('proxy' in setting ?
+ { ...getAgents(setting.proxy), proxy: !!setting.proxy } :
+ 'httpAgent' in setting ? {
+ httpAgent: setting.httpAgent,
+ httpsAgent: setting.httpsAgent,
+ proxy: true,
+ } :
+ { ...getAgents(proxy), proxy: !!proxy }),
+ };
+
+ console.log(config);
+}
+
+export default function (fastify: FastifyInstance, options: MediaProxyOptions | null | undefined, done: (err?: Error) => void) {
+ setMediaProxyConfig(options);
+
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Content-Security-Policy', `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`);
done();
@@ -57,9 +105,9 @@ function errorHandler(request: FastifyRequest<{ Params?: { [x: string]: any }; Q
}
async function proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) {
- const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url;
+ const url = 'url' in request.query ? request.query.url : (request.params.url && 'https://' + request.params.url);
- if (typeof url !== 'string') {
+ if (!url || typeof url !== 'string') {
reply.code(400);
return;
}
@@ -171,7 +219,7 @@ async function downloadAndDetectTypeFromUrl(url: string): Promise<
> {
const [path, cleanup] = await createTemp();
try {
- await downloadUrl(url, path);
+ await downloadUrl(url, path, config);
const { mime, ext } = await detectType(path);
diff --git a/tsconfig.json b/tsconfig.json
index 544b529..b9a7abf 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,7 +7,7 @@
"noUnusedParameters": false,
"noUnusedLocals": false,
"noFallthroughCasesInSwitch": true,
- "declaration": false,
+ "declaration": true,
"sourceMap": false,
"target": "es2021",
"module": "esnext",