diff --git a/README.md b/README.md index f24a0ad..2d60d72 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,93 @@ # Media Proxy for Misskey -Misskeyの/proxyが単体で動作します。 +Misskeyの/proxyが単体で動作します(Misskeyのコードがほぼそのまま移植されています)。 -## config.js +**Fastifyプラグインとして動作する気がします。** +`pnpm start`は[fastify-cli](https://github.com/fastify/fastify-cli)が動作します。 + +## セットアップ方法 +まずはgit cloneしてcdしてください。 + +``` +git clone https://github.com/misskey-dev/media-proxy.git +cd media-proxy +``` + +### pnpm install +``` +NODE_ENV=production pnpm install +``` + +### config.jsを追加 次のような内容で、設定ファイルconfig.jsをルートに作成してください。 ```js -const package = require('./package.json'); +import { readFileSync } from 'node:fs'; -module.exports = { +const repo = JSON.stringify(readFileSync('./package.json', 'utf8')); + +export default { + // UA userAgent: `MisskeyMediaProxy/${package.version}`, + + // プライベートネットワークでも許可するIP CIDR(default.ymlと同じ) allowedPrivateNetworks: [], + + // ダウンロードするファイルの最大サイズ maxSize: 262144000, + + // フォワードプロキシ // proxy: 'http://127.0.0.1:3128' } ``` + +### サーバーを立てる +適当にサーバーを公開してください。 +(ここではmediaproxy.example.comで公開するものとします。) + +メモ書き程度にsystemdでの開始方法を残しますが、もしかしたらAWS Lambdaとかで動かしたほうが楽かもしれません。 +(サーバーレスだとsharp.jsが動かない可能性が高いため、そこはなんとかしてください) + +systemdサービスのファイルを作成… + +/etc/systemd/system/misskey-proxy.service + +エディタで開き、以下のコードを貼り付けて保存(ユーザーやポートは適宜変更すること): + +```systemd +[Unit] +Description=Misskey Media Proxy + +[Service] +Type=simple +User=misskey +ExecStart=/usr/bin/npm start +WorkingDirectory=/home/misskey/media-proxy +Environment="NODE_ENV=production" +Environment="PORT=3000" +TimeoutSec=60 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=media-proxy +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +``` +sudo systemctl daemon-reload +sudo systemctl enable media-proxy +sudo systemctl start media-proxy +``` + +3000ポートまでnginxなどでルーティングしてやります。 + +### Misskeyのdefault.ymlに追記 + +mediaProxyの指定をdefault.ymlに追記し、Misskeyを再起動してください。 + +```yml +mediaProxy: https://mediaproxy.example.com/ +``` diff --git a/built/download.js b/built/download.js index 5aa25f5..6c2e683 100644 --- a/built/download.js +++ b/built/download.js @@ -1 +1 @@ -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){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}}).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}}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 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}}).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 diff --git a/built/index.js b/built/index.js index dcaff2a..7e891d4 100644 --- a/built/index.js +++ b/built/index.js @@ -1 +1 @@ -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("/proxy/: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&&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:128,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{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("/proxy/: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&&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:128,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 diff --git a/package.json b/package.json index 12e2694..76ea197 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "misskey-media-proxy", "version": "0.0.0", "description": "The Media Proxy for Misskey", - "main": "index.js", + "main": "built/index.js", "packageManager": "pnpm@7.26.0", "type": "module", "scripts": { "build": "swc src -d built -D", "watch": "swc src -d built -D -w & fastify start -w -l info -P ./built/index.js", - "start": "fastify start -l info ./built/index.js" + "start": "fastify start ./built/index.js" }, "repository": { "type": "git", diff --git a/src/download.ts b/src/download.ts index 59da333..c82adab 100644 --- a/src/download.ts +++ b/src/download.ts @@ -11,7 +11,7 @@ import { httpAgent, httpsAgent } from './http.js'; const pipeline = util.promisify(stream.pipeline); export async function downloadUrl(url: string, path: string): Promise { - console.log(`Downloading ${url} to ${path} ...`); + if (process.env.NODE_ENV !== 'production') console.log(`Downloading ${url} to ${path} ...`); const timeout = 30 * 1000; const operationTimeout = 60 * 1000; @@ -70,7 +70,7 @@ export async function downloadUrl(url: string, path: string): Promise { } } - console.log(`Download finished: ${url}`); + if (process.env.NODE_ENV !== 'production') console.log(`Download finished: ${url}`); } diff --git a/src/index.ts b/src/index.ts index 74e38ce..d40288f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,7 @@ export default function (fastify: FastifyInstance, options: FastifyPluginOptions fastify.get<{ Params: { url: string; }; Querystring: { url?: string; }; - }>('/proxy/:url*', async (request, reply) => { + }>('/:url*', async (request, reply) => { return await proxyHandler(request, reply) .catch(err => errorHandler(request, reply, err)); });