From 6462968b9d2df78c0851ae4c247f40384a9c3e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=82=E3=82=8F=E3=82=8F=E3=82=8F=E3=81=A8=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=85?= <17376330+u1-liquid@users.noreply.github.com> Date: Sat, 1 Feb 2025 22:57:44 +0900 Subject: [PATCH] =?UTF-8?q?spec(media-proxy):=20url=E3=82=92=E3=82=AF?= =?UTF-8?q?=E3=82=A8=E3=83=AA=E3=81=A7=E3=81=AF=E3=81=AA=E3=81=8F=E3=83=91?= =?UTF-8?q?=E3=83=A9=E3=83=A1=E3=83=BC=E3=82=BF=E3=81=A7=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=20(MisskeyIO#922)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/entities/DriveFileEntityService.ts | 3 +- .../backend/src/server/FileServerService.ts | 94 +++++++++++++------ packages/backend/src/server/ServerService.ts | 29 +++--- .../src/server/web/UrlPreviewService.ts | 19 ++-- packages/frontend/src/scripts/media-proxy.ts | 38 +++++--- 5 files changed, 116 insertions(+), 67 deletions(-) diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 289f267c4..97484b1ef 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -77,9 +77,8 @@ export class DriveFileEntityService { @bindThis private getProxiedUrl(url: string, mode?: 'static' | 'avatar'): string { return appendQuery( - `${this.config.mediaProxy}/${mode ?? 'image'}.webp`, + `${this.config.mediaProxy}/${mode ?? 'image'}/${encodeURIComponent(url)}`, query({ - url, ...(mode ? { [mode]: '1' } : {}), }), ); diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 61d758e7d..e1f86fd27 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -26,6 +26,7 @@ import { FileInfoService } from '@/core/FileInfoService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; +import { appendQuery, query } from '@/misc/prelude/url.js'; import { correctFilename } from '@/misc/correct-filename.js'; import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; @@ -35,6 +36,16 @@ const _dirname = dirname(_filename); const assets = `${_dirname}/../../server/file/assets/`; +interface TransformQuery { + origin?: string; + fallback?: string; + emoji?: string; + avatar?: string; + static?: string; + preview?: string; + badge?: string; +} + @Injectable() export class FileServerService { private logger: Logger; @@ -87,10 +98,18 @@ export class FileServerService { done(); }); + fastify.get<{ + Params: { type: string; url: string; }; + Querystring: { url?: string; } & TransformQuery; + }>('/proxy/:type/:url', async (request, reply) => { + return await this.proxyHandler(request, reply) + .catch(err => this.errorHandler(request, reply, err)); + }); + fastify.get<{ Params: { url: string; }; - Querystring: { url?: string; }; - }>('/proxy/:url*', async (request, reply) => { + Querystring: { url?: string; } & TransformQuery; + }>('/proxy/:url', async (request, reply) => { return await this.proxyHandler(request, reply) .catch(err => this.errorHandler(request, reply, err)); }); @@ -142,12 +161,15 @@ export class FileServerService { if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { reply.header('Cache-Control', 'max-age=31536000, immutable'); - const url = new URL(`${this.config.mediaProxy}/static.webp`); - url.searchParams.set('url', file.url); - url.searchParams.set('static', '1'); + const url = appendQuery( + `${this.config.mediaProxy}/static/${encodeURIComponent(file.url)}`, + query({ + static: '1', + }), + ); file.cleanup(); - return await reply.redirect(url.toString(), 301); + return await reply.redirect(url, 301); } else if (file.mime.startsWith('video/')) { const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); if (externalThumbnail) { @@ -163,11 +185,10 @@ export class FileServerService { if (['image/svg+xml'].includes(file.mime)) { reply.header('Cache-Control', 'max-age=31536000, immutable'); - const url = new URL(`${this.config.mediaProxy}/svg.webp`); - url.searchParams.set('url', file.url); + const url = `${this.config.mediaProxy}/svg/${encodeURIComponent(file.url)}`; file.cleanup(); - return await reply.redirect(url.toString(), 301); + return await reply.redirect(url, 301); } } @@ -291,30 +312,43 @@ export class FileServerService { } @bindThis - private async proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) { - const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; + private async proxyHandler(request: FastifyRequest<{ Params: { type?: string; url: string; }; Querystring: { url?: string; } & TransformQuery; }>, reply: FastifyReply) { + let url: string; + if ('url' in request.query && request.query.url) { + url = request.query.url; + } else { + url = request.params.url; + } - if (typeof url !== 'string') { + // noinspection HttpUrlsUsage + if (url + && !url.startsWith('http://') + && !url.startsWith('https://') + ) { + url = 'https://' + url; + } + + if (!url) { reply.code(400); return; } // アバタークロップなど、どうしてもオリジンである必要がある場合 const mustOrigin = 'origin' in request.query; + const transformQuery = request.query as TransformQuery; if (this.config.externalMediaProxyEnabled && !mustOrigin) { // 外部のメディアプロキシが有効なら、そちらにリダイレクト reply.header('Cache-Control', 'public, max-age=259200'); // 3 days - const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`); - - for (const [key, value] of Object.entries(request.query)) { - url.searchParams.append(key, value); - } + const url = appendQuery( + `${this.config.mediaProxy}/redirect/${encodeURIComponent(request.params.url)}`, + query(transformQuery as Record), + ); return reply.redirect( - url.toString(), + url, 301, ); } @@ -344,11 +378,11 @@ export class FileServerService { const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); if ( - 'emoji' in request.query || - 'avatar' in request.query || - 'static' in request.query || - 'preview' in request.query || - 'badge' in request.query + 'emoji' in transformQuery || + 'avatar' in transformQuery || + 'static' in transformQuery || + 'preview' in transformQuery || + 'badge' in transformQuery ) { if (!isConvertibleImage) { // 画像でないなら404でお茶を濁す @@ -357,17 +391,17 @@ export class FileServerService { } let image: IImageStreamable | null = null; - if ('emoji' in request.query || 'avatar' in request.query) { - if (!isAnimationConvertibleImage && !('static' in request.query)) { + if ('emoji' in transformQuery || 'avatar' in transformQuery) { + if (!isAnimationConvertibleImage && !('static' in transformQuery)) { image = { data: fs.createReadStream(file.path), ext: file.ext, type: file.mime, }; } else { - const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) + const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in transformQuery) })) .resize({ - height: 'emoji' in request.query ? 128 : 320, + height: 'emoji' in transformQuery ? 128 : 320, withoutEnlargement: true, }) .webp(webpDefault); @@ -378,11 +412,11 @@ export class FileServerService { type: 'image/webp', }; } - } else if ('static' in request.query) { + } else if ('static' in transformQuery) { image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); - } else if ('preview' in request.query) { + } else if ('preview' in transformQuery) { image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); - } else if ('badge' in request.query) { + } else if ('badge' in transformQuery) { const mask = (await sharpBmp(file.path, file.mime)) .resize(96, 96, { fit: 'contain', diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index f534a40bb..999812172 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -19,6 +19,7 @@ import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; import { genIdenticon } from '@/misc/gen-identicon.js'; +import { appendQuery, query } from '@/misc/prelude/url.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; @@ -162,22 +163,28 @@ export class ServerService implements OnApplicationShutdown { } } - let url: URL; + let url: string; if ('badge' in request.query) { - url = new URL(`${this.config.mediaProxy}/emoji.png`); - // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); - url.searchParams.set('badge', '1'); + url = appendQuery( + // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) + `${this.config.mediaProxy}/emoji/${encodeURIComponent(emoji.publicUrl || emoji.originalUrl)}`, + query({ + badge: '1', + }), + ); } else { - url = new URL(`${this.config.mediaProxy}/emoji.webp`); - // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); - url.searchParams.set('emoji', '1'); - if ('static' in request.query) url.searchParams.set('static', '1'); + url = appendQuery( + // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) + `${this.config.mediaProxy}/emoji/${encodeURIComponent(emoji.publicUrl || emoji.originalUrl)}`, + query({ + emoji: '1', + ...('static' in request.query ? { static: '1' } : {}), + }), + ); } return reply.redirect( - url.toString(), + url, 301, ); }); diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index f2be28de0..d018106a5 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -12,7 +12,7 @@ import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import type Logger from '@/logger.js'; -import { query } from '@/misc/prelude/url.js'; +import { appendQuery, query } from '@/misc/prelude/url.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import { ApiError } from '@/server/api/error.js'; @@ -36,14 +36,15 @@ export class UrlPreviewService { @bindThis private wrap(url?: string | null): string | null { - return url != null - ? url.match(/^https?:\/\//) - ? `${this.config.mediaProxy}/preview.webp?${query({ - url, - preview: '1', - })}` - : url - : null; + if (!url) return null; + if (!RegExp(/^https?:\/\//).exec(url)) return url; + + return appendQuery( + `${this.config.mediaProxy}/preview/${encodeURIComponent(url)}`, + query({ + preview: '1', + }), + ); } @bindThis diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts index 099a22163..9bd22609b 100644 --- a/packages/frontend/src/scripts/media-proxy.ts +++ b/packages/frontend/src/scripts/media-proxy.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { query } from '@/scripts/url.js'; +import { appendQuery, query } from '@/scripts/url.js'; import { url } from '@/config.js'; import { instance } from '@/instance.js'; @@ -12,18 +12,26 @@ export function getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) { // もう既にproxyっぽそうだったらurlを取り出す - imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl; + const url = (new URL(imageUrl)).searchParams.get('url'); + if (url) { + imageUrl = url; + } else if (imageUrl.startsWith(instance.mediaProxy + '/')) { + imageUrl = imageUrl.slice(instance.mediaProxy.length + 1); + } else if (imageUrl.startsWith('/proxy/')) { + imageUrl = imageUrl.slice('/proxy/'.length); + } else if (imageUrl.startsWith(localProxy + '/')) { + imageUrl = imageUrl.slice(localProxy.length + 1); + } } - return `${mustOrigin ? localProxy : instance.mediaProxy}/${ - type === 'preview' ? 'preview.webp' - : 'image.webp' - }?${query({ - url: imageUrl, - ...(!noFallback ? { 'fallback': '1' } : {}), - ...(type ? { [type]: '1' } : {}), - ...(mustOrigin ? { origin: '1' } : {}), - })}`; + return appendQuery( + `${mustOrigin ? localProxy : instance.mediaProxy}/${type === 'preview' ? 'preview' : 'image'}/${encodeURIComponent(imageUrl)}`, + query({ + ...(!noFallback ? { 'fallback': '1' } : {}), + ...(type ? { [type]: '1' } : {}), + ...(mustOrigin ? { origin: '1' } : {}), + }), + ); } export function getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null { @@ -46,8 +54,8 @@ export function getStaticImageUrl(baseUrl: string): string { return u.href; } - return `${instance.mediaProxy}/static.webp?${query({ - url: u.href, - static: '1', - })}`; + return appendQuery( + `${instance.mediaProxy}/static/${encodeURIComponent(u.href)}`, + query({ static: '1' }), + ); }