パッケージ公開に最適化

This commit is contained in:
tamaina 2023-02-05 12:34:04 +00:00
parent c6b14befe9
commit 85a7f47539
24 changed files with 737 additions and 60 deletions

View File

@ -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
```

1
built/const.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export declare const FILE_TYPE_BROWSERSAFE: string[];

View File

@ -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"];
// ブラウザで直接表示することを許可するファイルの種類のリスト
// ここに含まれないものは 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
*/

2
built/create-temp.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
export declare function createTemp(): Promise<[string, () => void]>;
export declare function createTempDir(): Promise<[string, () => void]>;

View File

@ -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:()=>{}])})})}
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 : () => { }]);
});
});
}

22
built/download.d.ts vendored Normal file
View File

@ -0,0 +1,22 @@
/// <reference types="node" />
/// <reference types="node" />
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<void>;

View File

@ -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}
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;
}

11
built/file-info.d.ts vendored Normal file
View File

@ -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 {};

View File

@ -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);
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);

8
built/http.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
/// <reference types="node" />
/// <reference types="node" />
import * as http from 'node:http';
import * as https from 'node:https';
export declare function getAgents(proxy?: string): {
httpAgent: http.Agent;
httpsAgent: https.Agent;
};

View File

@ -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;
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,
};
}

18
built/image-processor.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
/// <reference types="node" />
/// <reference types="node" />
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;

View File

@ -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"}}
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',
};
}

17
built/index.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
/// <reference types="node" />
/// <reference types="node" />
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;

View File

@ -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}}
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;
}
}

6
built/status-error.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
export declare class StatusError extends Error {
statusCode: number;
statusMessage?: string;
isClientError: boolean;
constructor(message: string, statusCode: number, statusMessage?: string);
}

View File

@ -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}}
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;
}
}

View File

@ -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",

34
pnpm-lock.yaml generated
View File

@ -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

6
server.js Normal file
View File

@ -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);
}

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
},
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<void> {
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<void> {
}
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;

View File

@ -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;
const httpsAgent = proxy
? new HttpsProxyAgent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
maxSockets: 256,
maxFreeSockets: 256,
scheduling: 'lifo',
proxy: proxy,
})
: _https;
return {
httpAgent,
httpsAgent,
};
}

View File

@ -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);

View File

@ -7,7 +7,7 @@
"noUnusedParameters": false,
"noUnusedLocals": false,
"noFallthroughCasesInSwitch": true,
"declaration": false,
"declaration": true,
"sourceMap": false,
"target": "es2021",
"module": "esnext",