7 Commits

Author SHA1 Message Date
384c51569f update SPECIFICATION.md 2023-03-17 15:51:04 +00:00
57aa87f370 0.0.19 2023-03-17 15:50:07 +00:00
d86823e32d 0.0.18 2023-03-17 13:44:13 +00:00
405e6a5adb quality, effort変更 2023-03-17 13:43:45 +00:00
78cf2469d4 0.0.17 2023-03-03 16:43:52 +00:00
969d27e1b3 0.0.16 2023-02-28 17:12:01 +00:00
d5f5f4023c 0.0.15 2023-02-28 16:06:23 +00:00
9 changed files with 85 additions and 33 deletions

View File

@ -26,7 +26,7 @@ Acceptヘッダーは無視される。
Cache-Controlは、正常なレスポンスの場合`max-age=31536000, immutable`、エラーレスポンスの場合`max-age=300`である。
Content-Typeは、ファイルの内容について適切なものが挿入される。
Content-Security-Policyは、`default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`となっている。
Content-Dispositionは、filenameは元画像のContent-Disposition.filenameもしくはファイル名に基づいて挿入される。inlineが指定される。
Content-Dispositionは、filenameは元画像のContent-Disposition.filenameもしくはファイル名に基づいて挿入される。拡張子は適宜変更され、octet-streamの場合は拡張子として.unknownが付加される。inlineが指定される。
### クエリの一覧
#### url (必須)
@ -52,6 +52,9 @@ https://www.google.com/images/errors/robot.png をプロキシする場合:
変換形式が指定されていなかった場合は、画像ファイルもしくは許可されたファイルFILE_TYPE_BROWSERSAFEである場合のみプロキシファイルの再配信が行われる。
ただし、svgは、webpに変換される最大サイズ2048x2048
#### 変換クエリ付加時の挙動
一方、以下の変換クエリが指定されているが、元ファイルがsharp.jsで変換できない形式の場合、404が返される。
#### emoji
存在すると、高さ128px以下のwebpが応答される。
ただし、sharp.jsの都合により、元画像がapngの場合は無変換で応答される。
@ -69,7 +72,7 @@ https://www.google.com/images/errors/robot.png をプロキシする場合:
#### static
存在すると、アニメーション画像では最初のフレームのみの静止画のwebpが応答される。
emojiまたはavatarとstaticが同時に指定された場合は、それぞれに応じた高さが、指定されていない場合は幅498px・高さ280pxに収まるサイズ以下に縮小される。
emojiまたはavatarとstaticが同時に指定された場合は、それぞれに応じた高さが、指定されていない場合は幅498px・高さ422pxに収まるサイズ以下に縮小される。
#### preview
存在すると、幅200px・高さ200pxに収まるサイズ以下のwebpが応答される。

View File

@ -55,7 +55,7 @@ async function checkSvg(path) {
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'],
'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
};
export const isMimeImage = (mime, type) => dictionary[type].includes(mime);

View File

@ -1,11 +1,12 @@
import sharp from 'sharp';
export const webpDefault = {
quality: 85,
quality: 77,
alphaQuality: 95,
lossless: false,
nearLossless: false,
smartSubsample: true,
mixed: true,
effort: 2,
};
export function convertToWebpStream(path, width, height, options = webpDefault) {
return convertSharpToWebpStream(sharp(path), width, height, options);

View File

@ -4,9 +4,10 @@ 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 { convertToWebpStream, webpDefault, convertSharpToWebpStream } from './image-processor.js';
import { detectType, isMimeImage } from './file-info.js';
import sharp from 'sharp';
import { sharpBmp } from 'sharp-read-bmp';
import { StatusError } from './status-error.js';
import { defaultDownloadConfig, downloadUrl } from './download.js';
import { getAgents } from './http.js';
@ -107,7 +108,7 @@ async function proxyHandler(request, reply) {
};
}
else {
const data = sharp(file.path, { animated: !('static' in request.query) })
const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
.resize({
height: 'emoji' in request.query ? 128 : 320,
withoutEnlargement: true,
@ -121,13 +122,13 @@ async function proxyHandler(request, reply) {
}
}
else if ('static' in request.query) {
image = convertToWebpStream(file.path, 498, 280);
image = convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422);
}
else if ('preview' in request.query) {
image = convertToWebpStream(file.path, 200, 200);
image = convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
}
else if ('badge' in request.query) {
const mask = sharp(file.path)
const mask = (await sharpBmp(file.path, file.mime))
.resize(96, 96, {
fit: 'inside',
withoutEnlargement: false,
@ -183,7 +184,7 @@ async function proxyHandler(request, reply) {
}
reply.header('Content-Type', image.type);
reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
reply.header('Content-Disposition', contentDisposition('inline', correctFilename(file.filename, image.ext)));
return reply.send(image.data);
}
catch (e) {
@ -210,15 +211,16 @@ async function downloadAndDetectTypeFromUrl(url) {
}
}
function correctFilename(filename, ext) {
if (!ext)
return filename;
const dotExt = `.${ext}`;
const dotExt = ext ? `.${ext}` : '.unknown';
if (filename.endsWith(dotExt)) {
return filename;
}
if (ext === 'jpg' && filename.endsWith('.jpeg')) {
return filename;
}
if (ext === 'tif' && filename.endsWith('.tiff')) {
return filename;
}
return `${filename}${dotExt}`;
}
function contentDisposition(type, filename) {

View File

@ -1,9 +1,9 @@
{
"name": "misskey-media-proxy",
"version": "0.0.14",
"version": "0.0.19",
"description": "The Media Proxy for Misskey",
"main": "built/index.js",
"packageManager": "pnpm@7.26.0",
"packageManager": "pnpm@7.28.0",
"type": "module",
"files": [
"built",
@ -47,6 +47,7 @@
"is-svg": "^4.3.2",
"private-ip": "^3.0.0",
"sharp": "^0.31.3",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"tmp": "^0.2.1"
}
}

37
pnpm-lock.yaml generated
View File

@ -19,6 +19,7 @@ specifiers:
is-svg: ^4.3.2
private-ip: ^3.0.0
sharp: ^0.31.3
sharp-read-bmp: github:misskey-dev/sharp-read-bmp
tmp: ^0.2.1
typescript: ^4.9.5
@ -35,6 +36,7 @@ dependencies:
is-svg: 4.3.2
private-ip: 3.0.0
sharp: 0.31.3
sharp-read-bmp: github.com/misskey-dev/sharp-read-bmp/c5fb82e26fa50fca8b3b2d7e22467626d7f61baa
tmp: 0.2.1
devDependencies:
@ -48,6 +50,10 @@ devDependencies:
packages:
/@canvas/image-data/1.0.0:
resolution: {integrity: sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==}
dev: false
/@chainsafe/is-ip/2.0.1:
resolution: {integrity: sha512-nqSJ8u2a1Rv9FYbyI8qpDhTYujaKEyLknNrTejLYoSWmdeg+2WB7R6BZqPZYfrJzDxVi3rl6ZQuoaEvpKRZWgQ==}
dev: false
@ -643,6 +649,23 @@ packages:
ms: 2.1.2
dev: false
/decode-bmp/0.2.1:
resolution: {integrity: sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==}
engines: {node: '>=8.6.0'}
dependencies:
'@canvas/image-data': 1.0.0
to-data-view: 1.1.0
dev: false
/decode-ico/0.4.1:
resolution: {integrity: sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA==}
engines: {node: '>=8.6'}
dependencies:
'@canvas/image-data': 1.0.0
decode-bmp: 0.2.1
to-data-view: 1.1.0
dev: false
/decompress-response/6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
@ -1978,6 +2001,10 @@ packages:
resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}
dev: false
/to-data-view/1.1.0:
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
dev: false
/to-regex-range/5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@ -2064,3 +2091,13 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
dev: false
github.com/misskey-dev/sharp-read-bmp/c5fb82e26fa50fca8b3b2d7e22467626d7f61baa:
resolution: {tarball: https://codeload.github.com/misskey-dev/sharp-read-bmp/tar.gz/c5fb82e26fa50fca8b3b2d7e22467626d7f61baa}
name: sharp-read-bmp
version: 1.0.0
dependencies:
decode-bmp: 0.2.1
decode-ico: 0.4.1
sharp: 0.31.3
dev: false

View File

@ -66,8 +66,8 @@ 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'],
'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
};
export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime);

View File

@ -16,12 +16,13 @@ export type IImageStream = {
export type IImageStreamable = IImage | IImageStream;
export const webpDefault: sharp.WebpOptions = {
quality: 85,
quality: 77,
alphaQuality: 95,
lossless: false,
nearLossless: false,
smartSubsample: true,
mixed: true,
effort: 2,
};
export function convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream {

View File

@ -6,10 +6,11 @@ import { dirname } from 'node:path';
import fastifyStatic from '@fastify/static';
import { createTemp } from './create-temp.js';
import { FILE_TYPE_BROWSERSAFE } from './const.js';
import { IImageStreamable, convertToWebpStream, webpDefault } from './image-processor.js';
import { IImageStreamable, convertToWebpStream, webpDefault, convertSharpToWebpStream } from './image-processor.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
import { detectType, isMimeImage } from './file-info.js';
import sharp from 'sharp';
import { sharpBmp } from 'sharp-read-bmp';
import { StatusError } from './status-error.js';
import { DownloadConfig, defaultDownloadConfig, downloadUrl } from './download.js';
import { getAgents } from './http.js';
@ -153,7 +154,7 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; };
type: file.mime,
};
} else {
const data = sharp(file.path, { animated: !('static' in request.query) })
const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
.resize({
height: 'emoji' in request.query ? 128 : 320,
withoutEnlargement: true,
@ -167,11 +168,11 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; };
};
}
} else if ('static' in request.query) {
image = convertToWebpStream(file.path, 498, 280);
image = convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422);
} else if ('preview' in request.query) {
image = convertToWebpStream(file.path, 200, 200);
image = convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
} else if ('badge' in request.query) {
const mask = sharp(file.path)
const mask = (await sharpBmp(file.path, file.mime))
.resize(96, 96, {
fit: 'inside',
withoutEnlargement: false,
@ -231,7 +232,12 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; };
reply.header('Content-Type', image.type);
reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
reply.header('Content-Disposition',
contentDisposition(
'inline',
correctFilename(file.filename, image.ext)
)
);
return reply.send(image.data);
} catch (e) {
if ('cleanup' in file) file.cleanup();
@ -261,15 +267,16 @@ async function downloadAndDetectTypeFromUrl(url: string): Promise<
}
function correctFilename(filename: string, ext: string | null) {
if (!ext) return filename;
const dotExt = `.${ext}`;
const dotExt = ext ? `.${ext}` : '.unknown';
if (filename.endsWith(dotExt)) {
return filename;
}
if (ext === 'jpg' && filename.endsWith('.jpeg')) {
return filename;
}
if (ext === 'tif' && filename.endsWith('.tiff')) {
return filename;
}
return `${filename}${dotExt}`;
}