mirror of
https://github.com/misskey-dev/media-proxy.git
synced 2025-04-29 02:47:26 +09:00
Content-Dispositionでダウンロード時の名前を指定
Fix https://github.com/misskey-dev/media-proxy/issues/6
This commit is contained in:
parent
808dacda41
commit
cfaf017c15
@ -1,32 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
parserOptions: {
|
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
project: ['./tsconfig.json'],
|
|
||||||
},
|
|
||||||
extends: [
|
|
||||||
'../shared/.eslintrc.js',
|
|
||||||
],
|
|
||||||
rules: {
|
|
||||||
'import/order': ['warn', {
|
|
||||||
'groups': ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
|
|
||||||
'pathGroups': [
|
|
||||||
{
|
|
||||||
'pattern': '@/**',
|
|
||||||
'group': 'external',
|
|
||||||
'position': 'after'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
'no-restricted-globals': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
'name': '__dirname',
|
|
||||||
'message': 'Not in ESModule. Use `import.meta.url` instead.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': '__filename',
|
|
||||||
'message': 'Not in ESModule. Use `import.meta.url` instead.'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
};
|
|
4
built/download.d.ts
vendored
4
built/download.d.ts
vendored
@ -19,4 +19,6 @@ export declare const defaultDownloadConfig: {
|
|||||||
maxSize: number;
|
maxSize: number;
|
||||||
proxy: boolean;
|
proxy: boolean;
|
||||||
};
|
};
|
||||||
export declare function downloadUrl(url: string, path: string, settings?: DownloadConfig): Promise<void>;
|
export declare function downloadUrl(url: string, path: string, settings?: DownloadConfig): Promise<{
|
||||||
|
filename: string;
|
||||||
|
}>;
|
||||||
|
@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr';
|
|||||||
import PrivateIp from 'private-ip';
|
import PrivateIp from 'private-ip';
|
||||||
import { StatusError } from './status-error.js';
|
import { StatusError } from './status-error.js';
|
||||||
import { getAgents } from './http.js';
|
import { getAgents } from './http.js';
|
||||||
|
import { parse } from 'content-disposition';
|
||||||
const pipeline = util.promisify(stream.pipeline);
|
const pipeline = util.promisify(stream.pipeline);
|
||||||
export const defaultDownloadConfig = {
|
export const defaultDownloadConfig = {
|
||||||
userAgent: `MisskeyMediaProxy/0.0.0`,
|
userAgent: `MisskeyMediaProxy/0.0.0`,
|
||||||
@ -19,6 +20,8 @@ export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
|
|||||||
console.log(`Downloading ${url} to ${path} ...`);
|
console.log(`Downloading ${url} to ${path} ...`);
|
||||||
const timeout = 30 * 1000;
|
const timeout = 30 * 1000;
|
||||||
const operationTimeout = 60 * 1000;
|
const operationTimeout = 60 * 1000;
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
let filename = urlObj.pathname.split('/').pop() ?? 'unknown';
|
||||||
const req = got.stream(url, {
|
const req = got.stream(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': settings.userAgent,
|
'User-Agent': settings.userAgent,
|
||||||
@ -56,6 +59,13 @@ export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
|
|||||||
req.destroy();
|
req.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const contentDisposition = res.headers['content-disposition'];
|
||||||
|
if (contentDisposition != null) {
|
||||||
|
const parsed = parse(contentDisposition);
|
||||||
|
if (parsed.parameters.filename) {
|
||||||
|
filename = parsed.parameters.filename;
|
||||||
|
}
|
||||||
|
}
|
||||||
}).on('downloadProgress', (progress) => {
|
}).on('downloadProgress', (progress) => {
|
||||||
if (progress.transferred > settings.maxSize) {
|
if (progress.transferred > settings.maxSize) {
|
||||||
console.log(`maxSize exceeded (${progress.transferred} > ${settings.maxSize}) on downloadProgress`);
|
console.log(`maxSize exceeded (${progress.transferred} > ${settings.maxSize}) on downloadProgress`);
|
||||||
@ -75,6 +85,10 @@ export async function downloadUrl(url, path, settings = defaultDownloadConfig) {
|
|||||||
}
|
}
|
||||||
if (process.env.NODE_ENV !== 'production')
|
if (process.env.NODE_ENV !== 'production')
|
||||||
console.log(`Download finished: ${url}`);
|
console.log(`Download finished: ${url}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
function isPrivateIp(ip, allowedPrivateNetworks) {
|
function isPrivateIp(ip, allowedPrivateNetworks) {
|
||||||
for (const net of allowedPrivateNetworks ?? []) {
|
for (const net of allowedPrivateNetworks ?? []) {
|
||||||
|
@ -10,6 +10,7 @@ import sharp from 'sharp';
|
|||||||
import { StatusError } from './status-error.js';
|
import { StatusError } from './status-error.js';
|
||||||
import { defaultDownloadConfig, downloadUrl } from './download.js';
|
import { defaultDownloadConfig, downloadUrl } from './download.js';
|
||||||
import { getAgents } from './http.js';
|
import { getAgents } from './http.js';
|
||||||
|
import _contentDisposition from 'content-disposition';
|
||||||
const _filename = fileURLToPath(import.meta.url);
|
const _filename = fileURLToPath(import.meta.url);
|
||||||
const _dirname = dirname(_filename);
|
const _dirname = dirname(_filename);
|
||||||
const assets = `${_dirname}/../assets/`;
|
const assets = `${_dirname}/../assets/`;
|
||||||
@ -182,6 +183,7 @@ async function proxyHandler(request, reply) {
|
|||||||
}
|
}
|
||||||
reply.header('Content-Type', image.type);
|
reply.header('Content-Type', image.type);
|
||||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||||
|
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
|
||||||
return reply.send(image.data);
|
return reply.send(image.data);
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
@ -193,12 +195,13 @@ async function proxyHandler(request, reply) {
|
|||||||
async function downloadAndDetectTypeFromUrl(url) {
|
async function downloadAndDetectTypeFromUrl(url) {
|
||||||
const [path, cleanup] = await createTemp();
|
const [path, cleanup] = await createTemp();
|
||||||
try {
|
try {
|
||||||
await downloadUrl(url, path, config);
|
const { filename } = await downloadUrl(url, path, config);
|
||||||
const { mime, ext } = await detectType(path);
|
const { mime, ext } = await detectType(path);
|
||||||
return {
|
return {
|
||||||
state: 'remote',
|
state: 'remote',
|
||||||
mime, ext,
|
mime, ext,
|
||||||
path, cleanup,
|
path, cleanup,
|
||||||
|
filename: correctFilename(filename, ext),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
@ -206,3 +209,19 @@ async function downloadAndDetectTypeFromUrl(url) {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function correctFilename(filename, ext) {
|
||||||
|
if (!ext)
|
||||||
|
return filename;
|
||||||
|
const dotExt = `.${ext}`;
|
||||||
|
if (filename.endsWith(dotExt)) {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
if (ext === 'jpg' && filename.endsWith('.jpeg')) {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
return `${filename}${dotExt}`;
|
||||||
|
}
|
||||||
|
function contentDisposition(type, filename) {
|
||||||
|
const fallback = filename.replace(/[^\w.-]/g, '_');
|
||||||
|
return _contentDisposition(filename, { type, fallback });
|
||||||
|
}
|
||||||
|
@ -28,6 +28,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@swc/cli": "^0.1.61",
|
"@swc/cli": "^0.1.61",
|
||||||
"@swc/core": "^1.3.32",
|
"@swc/core": "^1.3.32",
|
||||||
|
"@types/content-disposition": "^0.5.5",
|
||||||
"@types/node": "^18.11.19",
|
"@types/node": "^18.11.19",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
"@types/tmp": "^0.2.3",
|
"@types/tmp": "^0.2.3",
|
||||||
@ -36,6 +37,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/static": "^6.8.0",
|
"@fastify/static": "^6.8.0",
|
||||||
"cacheable-lookup": "^7.0.0",
|
"cacheable-lookup": "^7.0.0",
|
||||||
|
"content-disposition": "^0.5.4",
|
||||||
"fastify": "^4.12.0",
|
"fastify": "^4.12.0",
|
||||||
"fastify-cli": "^5.7.1",
|
"fastify-cli": "^5.7.1",
|
||||||
"file-type": "^18.2.0",
|
"file-type": "^18.2.0",
|
||||||
|
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -4,10 +4,12 @@ specifiers:
|
|||||||
'@fastify/static': ^6.8.0
|
'@fastify/static': ^6.8.0
|
||||||
'@swc/cli': ^0.1.61
|
'@swc/cli': ^0.1.61
|
||||||
'@swc/core': ^1.3.32
|
'@swc/core': ^1.3.32
|
||||||
|
'@types/content-disposition': ^0.5.5
|
||||||
'@types/node': ^18.11.19
|
'@types/node': ^18.11.19
|
||||||
'@types/sharp': ^0.31.1
|
'@types/sharp': ^0.31.1
|
||||||
'@types/tmp': ^0.2.3
|
'@types/tmp': ^0.2.3
|
||||||
cacheable-lookup: ^7.0.0
|
cacheable-lookup: ^7.0.0
|
||||||
|
content-disposition: ^0.5.4
|
||||||
fastify: ^4.12.0
|
fastify: ^4.12.0
|
||||||
fastify-cli: ^5.7.1
|
fastify-cli: ^5.7.1
|
||||||
file-type: ^18.2.0
|
file-type: ^18.2.0
|
||||||
@ -23,6 +25,7 @@ specifiers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/static': 6.8.0
|
'@fastify/static': 6.8.0
|
||||||
cacheable-lookup: 7.0.0
|
cacheable-lookup: 7.0.0
|
||||||
|
content-disposition: 0.5.4
|
||||||
fastify: 4.12.0
|
fastify: 4.12.0
|
||||||
fastify-cli: 5.7.1
|
fastify-cli: 5.7.1
|
||||||
file-type: 18.2.0
|
file-type: 18.2.0
|
||||||
@ -37,6 +40,7 @@ dependencies:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@swc/cli': 0.1.61_@swc+core@1.3.32
|
'@swc/cli': 0.1.61_@swc+core@1.3.32
|
||||||
'@swc/core': 1.3.32
|
'@swc/core': 1.3.32
|
||||||
|
'@types/content-disposition': 0.5.5
|
||||||
'@types/node': 18.11.19
|
'@types/node': 18.11.19
|
||||||
'@types/sharp': 0.31.1
|
'@types/sharp': 0.31.1
|
||||||
'@types/tmp': 0.2.3
|
'@types/tmp': 0.2.3
|
||||||
@ -300,6 +304,10 @@ packages:
|
|||||||
'@types/responselike': 1.0.0
|
'@types/responselike': 1.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/content-disposition/0.5.5:
|
||||||
|
resolution: {integrity: sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/http-cache-semantics/4.0.1:
|
/@types/http-cache-semantics/4.0.1:
|
||||||
resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==}
|
resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==}
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import IPCIDR from 'ip-cidr';
|
|||||||
import PrivateIp from 'private-ip';
|
import PrivateIp from 'private-ip';
|
||||||
import { StatusError } from './status-error.js';
|
import { StatusError } from './status-error.js';
|
||||||
import { getAgents } from './http.js';
|
import { getAgents } from './http.js';
|
||||||
|
import { parse } from 'content-disposition';
|
||||||
|
|
||||||
const pipeline = util.promisify(stream.pipeline);
|
const pipeline = util.promisify(stream.pipeline);
|
||||||
|
|
||||||
@ -29,12 +30,17 @@ export const defaultDownloadConfig = {
|
|||||||
...getAgents()
|
...getAgents()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadUrl(url: string, path: string, settings:DownloadConfig = defaultDownloadConfig): Promise<void> {
|
export async function downloadUrl(url: string, path: string, settings:DownloadConfig = defaultDownloadConfig): Promise<{
|
||||||
|
filename: string;
|
||||||
|
}> {
|
||||||
if (process.env.NODE_ENV !== 'production') console.log(`Downloading ${url} to ${path} ...`);
|
if (process.env.NODE_ENV !== 'production') console.log(`Downloading ${url} to ${path} ...`);
|
||||||
|
|
||||||
const timeout = 30 * 1000;
|
const timeout = 30 * 1000;
|
||||||
const operationTimeout = 60 * 1000;
|
const operationTimeout = 60 * 1000;
|
||||||
|
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
let filename = urlObj.pathname.split('/').pop() ?? 'unknown';
|
||||||
|
|
||||||
const req = got.stream(url, {
|
const req = got.stream(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': settings.userAgent,
|
'User-Agent': settings.userAgent,
|
||||||
@ -73,6 +79,14 @@ export async function downloadUrl(url: string, path: string, settings:DownloadCo
|
|||||||
req.destroy();
|
req.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentDisposition = res.headers['content-disposition'];
|
||||||
|
if (contentDisposition != null) {
|
||||||
|
const parsed = parse(contentDisposition);
|
||||||
|
if (parsed.parameters.filename) {
|
||||||
|
filename = parsed.parameters.filename;
|
||||||
|
}
|
||||||
|
}
|
||||||
}).on('downloadProgress', (progress: Got.Progress) => {
|
}).on('downloadProgress', (progress: Got.Progress) => {
|
||||||
if (progress.transferred > settings.maxSize) {
|
if (progress.transferred > settings.maxSize) {
|
||||||
console.log(`maxSize exceeded (${progress.transferred} > ${settings.maxSize}) on downloadProgress`);
|
console.log(`maxSize exceeded (${progress.transferred} > ${settings.maxSize}) on downloadProgress`);
|
||||||
@ -91,6 +105,10 @@ export async function downloadUrl(url: string, path: string, settings:DownloadCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') console.log(`Download finished: ${url}`);
|
if (process.env.NODE_ENV !== 'production') console.log(`Download finished: ${url}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrivateIp(ip: string, allowedPrivateNetworks: string[]): boolean {
|
function isPrivateIp(ip: string, allowedPrivateNetworks: string[]): boolean {
|
||||||
|
25
src/index.ts
25
src/index.ts
@ -13,6 +13,7 @@ import sharp from 'sharp';
|
|||||||
import { StatusError } from './status-error.js';
|
import { StatusError } from './status-error.js';
|
||||||
import { DownloadConfig, defaultDownloadConfig, downloadUrl } from './download.js';
|
import { DownloadConfig, defaultDownloadConfig, downloadUrl } from './download.js';
|
||||||
import { getAgents } from './http.js';
|
import { getAgents } from './http.js';
|
||||||
|
import _contentDisposition from 'content-disposition';
|
||||||
|
|
||||||
const _filename = fileURLToPath(import.meta.url);
|
const _filename = fileURLToPath(import.meta.url);
|
||||||
const _dirname = dirname(_filename);
|
const _dirname = dirname(_filename);
|
||||||
@ -230,6 +231,7 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; };
|
|||||||
|
|
||||||
reply.header('Content-Type', image.type);
|
reply.header('Content-Type', image.type);
|
||||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||||
|
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
|
||||||
return reply.send(image.data);
|
return reply.send(image.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ('cleanup' in file) file.cleanup();
|
if ('cleanup' in file) file.cleanup();
|
||||||
@ -238,11 +240,11 @@ async function proxyHandler(request: FastifyRequest<{ Params: { url: string; };
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadAndDetectTypeFromUrl(url: string): Promise<
|
async function downloadAndDetectTypeFromUrl(url: string): Promise<
|
||||||
{ state: 'remote'; mime: string; ext: string | null; path: string; cleanup: () => void; }
|
{ state: 'remote'; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
|
||||||
> {
|
> {
|
||||||
const [path, cleanup] = await createTemp();
|
const [path, cleanup] = await createTemp();
|
||||||
try {
|
try {
|
||||||
await downloadUrl(url, path, config);
|
const { filename } = await downloadUrl(url, path, config);
|
||||||
|
|
||||||
const { mime, ext } = await detectType(path);
|
const { mime, ext } = await detectType(path);
|
||||||
|
|
||||||
@ -250,9 +252,28 @@ async function downloadAndDetectTypeFromUrl(url: string): Promise<
|
|||||||
state: 'remote',
|
state: 'remote',
|
||||||
mime, ext,
|
mime, ext,
|
||||||
path, cleanup,
|
path, cleanup,
|
||||||
|
filename: correctFilename(filename, ext),
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
cleanup();
|
cleanup();
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function correctFilename(filename: string, ext: string | null) {
|
||||||
|
if (!ext) return filename;
|
||||||
|
|
||||||
|
const dotExt = `.${ext}`;
|
||||||
|
if (filename.endsWith(dotExt)) {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
if (ext === 'jpg' && filename.endsWith('.jpeg')) {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
return `${filename}${dotExt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentDisposition(type: 'inline' | 'attachment', filename: string): string {
|
||||||
|
const fallback = filename.replace(/[^\w.-]/g, '_');
|
||||||
|
return _contentDisposition(filename, { type, fallback });
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user