From 4ba0357d4914b026b812cc3c27f9e4380b5375c6 Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Tue, 19 Nov 2024 21:07:02 -0600 Subject: [PATCH 1/2] fix(backend): Atomically mark remote account deletions Signed-off-by: eternal-flame-AD --- .../1732071810971-IndexUserDeleted.js | 16 +++++ .../backend/src/core/DeleteAccountService.ts | 4 ++ packages/backend/src/core/RoleService.ts | 1 + .../src/core/activitypub/ApInboxService.ts | 9 +-- .../activitypub/models/ApPersonService.ts | 6 +- .../DeleteAccountProcessorService.ts | 62 ++++++++++++++++--- .../src/server/api/endpoints/users/show.ts | 1 + 7 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 packages/backend/migration/1732071810971-IndexUserDeleted.js diff --git a/packages/backend/migration/1732071810971-IndexUserDeleted.js b/packages/backend/migration/1732071810971-IndexUserDeleted.js new file mode 100644 index 0000000000..b4c3d714ad --- /dev/null +++ b/packages/backend/migration/1732071810971-IndexUserDeleted.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project and yumechi + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class IndexUserDeleted1732071810971 { + name = 'IndexUserDeleted1732071810971' + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_199b79e682bdc5ba946f491686" ON "user" ("isDeleted")`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_199b79e682bdc5ba946f491686"`); + } +} diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 7f1b8f3efb..a5b0f60fbf 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -47,6 +47,10 @@ export class DeleteAccountService { }); } + if (!(await this.usersRepository.update({ id: user.id, isDeleted: false }, { isDeleted: true })).affected) { + return; + } + // 物理削除する前にDelete activityを送信する if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにDelete配信 diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 5af6b05942..ba4507d206 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -488,6 +488,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { return ids.length > 0 ? await this.usersRepository.findBy({ id: In(ids), + isDeleted: false, }) : []; } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index f3aa46292e..fccf86cb91 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -509,19 +509,12 @@ export class ApInboxService { return `skip: delete actor ${actor.uri} !== ${uri}`; } - const user = await this.usersRepository.findOneBy({ id: actor.id }); - if (user == null) { - return 'skip: actor not found'; - } else if (user.isDeleted) { + if (!(await this.usersRepository.update({ id: actor.id, isDeleted: false }, { isDeleted: true })).affected) { return 'skip: already deleted'; } const job = await this.queueService.createDeleteAccountJob(actor); - await this.usersRepository.update(actor.id, { - isDeleted: true, - }); - this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: actor.id }); return `ok: queued ${job.name} ${job.id}`; diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 8c4e40c561..e01b098194 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -557,7 +557,9 @@ export class ApPersonService implements OnModuleInit { if (moving) updates.movedAt = new Date(); // Update user - await this.usersRepository.update(exist.id, updates); + if (!(await this.usersRepository.update({ id: exist.id, isDeleted: false }, updates)).affected) { + return 'skip'; + } if (person.publicKey) { await this.userPublickeysRepository.update({ userId: exist.id }, { @@ -662,7 +664,7 @@ export class ApPersonService implements OnModuleInit { @bindThis public async updateFeatured(userId: MiUser['id'], resolver?: Resolver): Promise { - const user = await this.usersRepository.findOneByOrFail({ id: userId }); + const user = await this.usersRepository.findOneByOrFail({ id: userId, isDeleted: false }); if (!this.userEntityService.isRemoteUser(user)) return; if (!user.featured) return; diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 14a53e0c42..05be2e02f0 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -4,9 +4,9 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; +import { DataSource, MoreThan, QueryFailedError, TypeORMError } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { MiUser, type DriveFilesRepository, type NotesRepository, type UserProfilesRepository, type UsersRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; @@ -26,6 +26,9 @@ export class DeleteAccountProcessorService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, + @Inject(DI.db) + private db: DataSource, + @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, @@ -52,6 +55,14 @@ export class DeleteAccountProcessorService { return; } + if (!user.isDeleted) { + this.logger.warn('User is not pre-marked as deleted, this is likely a bug'); + if (process.env.NODE_ENV !== 'production') { + throw new Error('User is not pre-marked as deleted'); // make some noise to make sure tests fail + } + await this.usersRepository.update({ id: user.id }, { isDeleted: true }); + } + { // Delete notes let cursor: MiNote['id'] | null = null; @@ -121,13 +132,46 @@ export class DeleteAccountProcessorService { } } - // soft指定されている場合は物理削除しない - if (job.data.soft) { - // nop - } else { - await this.usersRepository.delete(job.data.user.id); + // Deadlockが発生した場合にリトライする + for (let remaining = 3; remaining > 0; remaining--) { + try { + // soft指定されている場合は物理削除しない + await this.db.transaction(async txn => { + // soft指定してもデータをすべで削除する + await txn.delete(MiUser, user.id); + if (job.data.soft) { + await txn.insert(MiUser, { + ...user, + isRoot: false, + updatedAt: new Date(), + emojis: [], + hideOnlineStatus: true, + followersCount: 0, + followingCount: 0, + avatarUrl: null, + avatarId: null, + notesCount: 0, + inbox: null, + sharedInbox: null, + featured: null, + uri: null, + followersUri: null, + token: null, + isDeleted: true, + }); + } + }); + return 'Account deleted'; + } catch (e) { + // 40P01 = deadlock_detected + // https://www.postgresql.org/docs/current/errcodes-appendix.html + if (remaining > 0 && e instanceof QueryFailedError && e.driverError.code === '40P01') { + this.logger.warn(`Deadlock occurred, retrying after 1s... [${remaining - 1} remaining]`); + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + throw e; + } } - - return 'Account deleted'; } } diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 062326e28d..6daed35372 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -106,6 +106,7 @@ export default class extends Endpoint { // eslint- id: In(ps.userIds), } : { id: In(ps.userIds), + isDeleted: false, isSuspended: false, }); -- 2.45.2 From 95d3fb08f40adfcd5535fa4d199158640b51d305 Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Wed, 20 Nov 2024 01:59:48 -0600 Subject: [PATCH 2/2] Prefix all calls to Image and Video Processing service Signed-off-by: eternal-flame-AD --- packages/backend/src/core/CoreModule.ts | 16 +- packages/backend/src/core/DriveService.ts | 18 +- .../src/core/ImageProcessingService.ts | 4 +- .../src/core/VideoProcessingService.ts | 8 +- .../core/entities/DriveFileEntityService.ts | 8 +- .../backend/src/server/FileServerService.ts | 481 ++++-------------- 6 files changed, 126 insertions(+), 409 deletions(-) diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 734d135648..9fdaf5eb86 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -36,7 +36,7 @@ import { GlobalEventService } from './GlobalEventService.js'; import { HashtagService } from './HashtagService.js'; import { HttpRequestService } from './HttpRequestService.js'; import { IdService } from './IdService.js'; -import { ImageProcessingService } from './ImageProcessingService.js'; +import { __YUME_PRIVATE_ImageProcessingService } from './ImageProcessingService.js'; import { InstanceActorService } from './InstanceActorService.js'; import { InternalStorageService } from './InternalStorageService.js'; import { MetaService } from './MetaService.js'; @@ -67,7 +67,7 @@ import { UserMutingService } from './UserMutingService.js'; import { UserRenoteMutingService } from './UserRenoteMutingService.js'; import { UserSuspendService } from './UserSuspendService.js'; import { UserAuthService } from './UserAuthService.js'; -import { VideoProcessingService } from './VideoProcessingService.js'; +import { __YUME_PRIVATE_VideoProcessingService } from './VideoProcessingService.js'; import { UserWebhookService } from './UserWebhookService.js'; import { ProxyAccountService } from './ProxyAccountService.js'; import { UtilityService } from './UtilityService.js'; @@ -179,7 +179,7 @@ const $GlobalEventService: Provider = { provide: 'GlobalEventService', useExisti const $HashtagService: Provider = { provide: 'HashtagService', useExisting: HashtagService }; const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService }; const $IdService: Provider = { provide: 'IdService', useExisting: IdService }; -const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService }; +const $ImageProcessingService: Provider = { provide: '__YUME_PRIVATE_ImageProcessingService', useExisting: __YUME_PRIVATE_ImageProcessingService }; const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService }; const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService }; const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService }; @@ -212,7 +212,7 @@ const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService', const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; -const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; +const $VideoProcessingService: Provider = { provide: '__YUME_PRIVATE_VideoProcessingService', useExisting: __YUME_PRIVATE_VideoProcessingService }; const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService }; const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService }; const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService }; @@ -330,7 +330,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting HashtagService, HttpRequestService, IdService, - ImageProcessingService, + __YUME_PRIVATE_ImageProcessingService, InstanceActorService, InternalStorageService, MetaService, @@ -363,7 +363,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserSearchService, UserSuspendService, UserAuthService, - VideoProcessingService, + __YUME_PRIVATE_VideoProcessingService, UserWebhookService, SystemWebhookService, WebhookTestService, @@ -625,7 +625,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting HashtagService, HttpRequestService, IdService, - ImageProcessingService, + __YUME_PRIVATE_ImageProcessingService, InstanceActorService, InternalStorageService, MetaService, @@ -658,7 +658,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserSearchService, UserSuspendService, UserAuthService, - VideoProcessingService, + __YUME_PRIVATE_VideoProcessingService, UserWebhookService, SystemWebhookService, WebhookTestService, diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 495d67a93b..517a682753 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -22,8 +22,8 @@ import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { contentDisposition } from '@/misc/content-disposition.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { VideoProcessingService } from '@/core/VideoProcessingService.js'; -import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import { __YUME_PRIVATE_VideoProcessingService } from '@/core/VideoProcessingService.js'; +import { __YUME_PRIVATE_ImageProcessingService } from '@/core/ImageProcessingService.js'; import type { IImage } from '@/core/ImageProcessingService.js'; import { QueueService } from '@/core/QueueService.js'; import type { MiDriveFolder } from '@/models/DriveFolder.js'; @@ -120,8 +120,8 @@ export class DriveService { private downloadService: DownloadService, private internalStorageService: InternalStorageService, private s3Service: S3Service, - private imageProcessingService: ImageProcessingService, - private videoProcessingService: VideoProcessingService, + private privateImageProcessingService: __YUME_PRIVATE_ImageProcessingService, + private privateVideoProcessingService: __YUME_PRIVATE_VideoProcessingService, private globalEventService: GlobalEventService, private queueService: QueueService, private roleService: RoleService, @@ -277,7 +277,7 @@ export class DriveService { } try { - const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path); + const thumbnail = await this.privateVideoProcessingService.generateVideoThumbnail(path); return { webpublic: null, thumbnail, @@ -331,9 +331,9 @@ export class DriveService { try { if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) { - webpublic = await this.imageProcessingService.convertSharpToWebp(img, 2048, 2048); + webpublic = await this.privateImageProcessingService.convertSharpToWebp(img, 2048, 2048); } else if (['image/png', 'image/bmp', 'image/svg+xml'].includes(type)) { - webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); + webpublic = await this.privateImageProcessingService.convertSharpToPng(img, 2048, 2048); } else { this.registerLogger.debug('web image not created (not an required image)'); } @@ -352,9 +352,9 @@ export class DriveService { try { if (isAnimated) { - thumbnail = await this.imageProcessingService.convertSharpToWebp(sharp(path, { animated: true }), 374, 317, { alphaQuality: 70 }); + thumbnail = await this.privateImageProcessingService.convertSharpToWebp(sharp(path, { animated: true }), 374, 317, { alphaQuality: 70 }); } else { - thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422); + thumbnail = await this.privateImageProcessingService.convertSharpToWebp(img, 498, 422); } } catch (err) { this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error); diff --git a/packages/backend/src/core/ImageProcessingService.ts b/packages/backend/src/core/ImageProcessingService.ts index 6f978b34c8..6aa25decc8 100644 --- a/packages/backend/src/core/ImageProcessingService.ts +++ b/packages/backend/src/core/ImageProcessingService.ts @@ -46,7 +46,9 @@ import { bindThis } from '@/decorators.js'; import { Readable } from 'node:stream'; @Injectable() -export class ImageProcessingService { +// Prevent accidental import by upstream merge +// eslint-disable-next-line +export class __YUME_PRIVATE_ImageProcessingService { constructor( ) { } diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts index 747fe4fc7e..461e427b0d 100644 --- a/packages/backend/src/core/VideoProcessingService.ts +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -7,19 +7,21 @@ import { Inject, Injectable } from '@nestjs/common'; import FFmpeg from 'fluent-ffmpeg'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import { __YUME_PRIVATE_ImageProcessingService } from '@/core/ImageProcessingService.js'; import type { IImage } from '@/core/ImageProcessingService.js'; import { createTempDir } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { appendQuery, query } from '@/misc/prelude/url.js'; @Injectable() -export class VideoProcessingService { +// Prevent accidental import by upstream merge +// eslint-disable-next-line +export class __YUME_PRIVATE_VideoProcessingService { constructor( @Inject(DI.config) private config: Config, - private imageProcessingService: ImageProcessingService, + private imageProcessingService: __YUME_PRIVATE_ImageProcessingService, ) { } diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index c485555f90..a1dbef36da 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -18,7 +18,6 @@ import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; import { IdService } from '@/core/IdService.js'; import { UtilityService } from '../UtilityService.js'; -import { VideoProcessingService } from '../VideoProcessingService.js'; import { UserEntityService } from './UserEntityService.js'; import { DriveFolderEntityService } from './DriveFolderEntityService.js'; @@ -43,7 +42,6 @@ export class DriveFileEntityService { private utilityService: UtilityService, private driveFolderEntityService: DriveFolderEntityService, - private videoProcessingService: VideoProcessingService, private idService: IdService, ) { } @@ -86,11 +84,7 @@ export class DriveFileEntityService { @bindThis public getThumbnailUrl(file: MiDriveFile): string | null { - if (file.type.startsWith('video')) { - if (file.thumbnailUrl) return file.thumbnailUrl; - - return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url); - } else if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { + if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { // 動画ではなくリモートかつメディアプロキシ return this.getProxiedUrl(file.uri, 'static'); } diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 91d826382d..1bdcdbe2a0 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -8,27 +8,19 @@ import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; import rename from 'rename'; -import sharp from 'sharp'; -import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import type { Config } from '@/config.js'; import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; -import { createTemp } from '@/misc/create-temp.js'; import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import { StatusError } from '@/misc/status-error.js'; import type Logger from '@/logger.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; -import { VideoProcessingService } from '@/core/VideoProcessingService.js'; -import { InternalStorageService } from '@/core/InternalStorageService.js'; import { contentDisposition } from '@/misc/content-disposition.js'; 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 { correctFilename } from '@/misc/correct-filename.js'; import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; +import { InternalStorageService } from '@/core/InternalStorageService.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -46,11 +38,8 @@ export class FileServerService { @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private fileInfoService: FileInfoService, - private downloadService: DownloadService, - private imageProcessingService: ImageProcessingService, - private videoProcessingService: VideoProcessingService, private internalStorageService: InternalStorageService, + private fileInfoService: FileInfoService, private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('server', 'gray'); @@ -134,165 +123,78 @@ export class FileServerService { return; } - try { - if (file.state === 'remote') { - let image: IImageStreamable | null = null; + if (file.state === 'remote') { + const url = new URL(`${this.config.mediaProxy}/`); - if (file.fileRole === 'thumbnail') { - if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); + url.searchParams.set('url', file.url); - const url = new URL(`${this.config.mediaProxy}/static.webp`); - url.searchParams.set('url', file.url); - url.searchParams.set('static', '1'); + return await reply.redirect(url.toString(), 301); + } - file.cleanup(); - return await reply.redirect(url.toString(), 301); - } else if (file.mime.startsWith('video/')) { - const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); - if (externalThumbnail) { - file.cleanup(); - return await reply.redirect(externalThumbnail, 301); - } + if (file.fileRole !== 'original') { + const filename = rename(file.filename, { + suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', + extname: file.ext ? `.${file.ext}` : '.unknown', + }).toString(); - image = await this.videoProcessingService.generateVideoThumbnail(file.path); - } + reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', contentDisposition('inline', filename)); + + if (request.headers.range && file.file.size > 0) { + const range = request.headers.range as string; + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; + if (end > file.file.size) { + end = file.file.size - 1; } - - if (file.fileRole === 'webpublic') { - 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); - - file.cleanup(); - return await reply.redirect(url.toString(), 301); - } - } - - if (!image) { - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - - image = { - data: fs.createReadStream(file.path, { - start, - end, - }), - ext: file.ext, - type: file.mime, - }; - - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - } else { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } - } - - 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', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); - reply.header('Content-Length', file.file.size); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; + const chunksize = end - start + 1; + const fileStream = fs.createReadStream(file.path, { + start, + end, + }); + reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + reply.code(206); + return fileStream; } - if (file.fileRole !== 'original') { - const filename = rename(file.filename, { - suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', - extname: file.ext ? `.${file.ext}` : '.unknown', - }).toString(); + return fs.createReadStream(file.path); + } else { + reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); + reply.header('Content-Length', file.file.size); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', contentDisposition('inline', file.filename)); - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', filename)); - - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - const fileStream = fs.createReadStream(file.path, { - start, - end, - }); - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - return fileStream; + if (request.headers.range && file.file.size > 0) { + const range = request.headers.range as string; + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; + if (end > file.file.size) { + end = file.file.size - 1; } - - return fs.createReadStream(file.path); - } else { - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); - reply.header('Content-Length', file.file.size); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', file.filename)); - - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - const fileStream = fs.createReadStream(file.path, { - start, - end, - }); - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - return fileStream; - } - - return fs.createReadStream(file.path); + const chunksize = end - start + 1; + const fileStream = fs.createReadStream(file.path, { + start, + end, + }); + reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + reply.code(206); + return fileStream; } - } catch (e) { - if ('cleanup' in file) file.cleanup(); - throw e; + + return fs.createReadStream(file.path); } } @bindThis private async proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) { - let url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; + const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; if (typeof url !== 'string') { reply.code(400); @@ -302,234 +204,56 @@ export class FileServerService { // アバタークロップなど、どうしてもオリジンである必要がある場合 const mustOrigin = 'origin' in request.query; - if (this.config.externalMediaProxyEnabled) { - // 外部のメディアプロキシが有効なら、そちらにリダイレクト - - reply.header('Cache-Control', 'public, max-age=259200'); // 3 days - - const externalURL = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`); - - for (const [key, value] of Object.entries(request.query)) { - externalURL.searchParams.append(key, value); - } - - if (mustOrigin) { - url = `${this.config.mediaProxy}?url=${encodeURIComponent(url)}`; - } else { - return await reply.redirect( - externalURL.toString(), - 301, - ); - } + if (!this.config.mediaProxy) { + reply.code(501); } - if (!request.headers['user-agent']) { - throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); - } else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { - throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); + const proxiedURL = new URL(`${this.config.mediaProxy}/?url=${encodeURIComponent(url)}`); + + for (const [key, value] of Object.entries(request.query)) { + if (key.toLowerCase() === 'url') continue; + proxiedURL.searchParams.append(key, value); } - if (!request.headers['user-agent']) { - throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); - } else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { - throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); - } - - // Create temp file - const file = await this.getStreamAndTypeFromUrl(url); - if (file === '404') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', assets); - } - - if (file === '204') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; - } - - try { - const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); - 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 - ) { - if (!isConvertibleImage) { - // 画像でないなら404でお茶を濁す - throw new StatusError('Unexpected mime', 404); - } - } - - let image: IImageStreamable | null = null; - if ('emoji' in request.query || 'avatar' in request.query) { - if (!isAnimationConvertibleImage && !('static' in request.query)) { - 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) })) - .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) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); - } else if ('preview' in request.query) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); - } else if ('badge' in request.query) { - const mask = (await sharpBmp(file.path, file.mime)) - .resize(96, 96, { - fit: 'contain', - position: 'centre', - 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 = this.imageProcessingService.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) { - if (request.headers.range && file.file && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - - image = { - data: fs.createReadStream(file.path, { - start, - end, - }), - ext: file.ext, - type: file.mime, - }; - - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - } else { - 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'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), + if (!mustOrigin) { + return await reply.redirect( + proxiedURL.toString(), + 301, ); - return image.data; - } catch (e) { - if ('cleanup' in file) file.cleanup(); - throw e; - } - } - - @bindThis - private async getStreamAndTypeFromUrl(url: string): Promise< - { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } - | '404' - | '204' - > { - if (url.startsWith(`${this.config.url}/files/`)) { - const key = url.replace(`${this.config.url}/files/`, '').split('/').shift(); - if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key'); - - return await this.getFileFromKey(key); } - return await this.downloadAndDetectTypeFromUrl(url); - } + reply.header('Cache-Control', 'public, max-age=259200'); // 3 days - @bindThis - private async downloadAndDetectTypeFromUrl(url: string): Promise< - { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } - > { - const [path, cleanup] = await createTemp(); - try { - const { filename } = await this.downloadService.downloadUrl(url, path); - - const { mime, ext } = await this.fileInfoService.detectType(path); - - return { - state: 'remote', - mime, ext, - path, cleanup, - filename, - }; - } catch (e) { - cleanup(); - throw e; + if (!request.headers['user-agent']) { + throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); + } else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { + throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); } + + if (!request.headers['user-agent']) { + throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); + } else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { + throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); + } + + // directly proxy request through + const res = await fetch(proxiedURL, { + headers: { + 'X-Forwarded-For': request.headers['x-forwarded-for']?.at(0) ?? request.ip, + 'User-Agent': request.headers['user-agent'], + }, + }); + + reply.code(res.status); + for (const [key, value] of res.headers.entries()) { + reply.header(key, value); + } + reply.send(res.body); } @bindThis private async getFileFromKey(key: string): Promise< - { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } + { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; filename: string; url: string; } | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } | '404' | '204' @@ -548,15 +272,10 @@ export class FileServerService { if (!file.storedInternal) { if (!(file.isLink && file.uri)) return '204'; - const result = await this.downloadAndDetectTypeFromUrl(file.uri); - file.size = (await fs.promises.stat(result.path)).size; // DB file.sizeは正確とは限らないので - return { - ...result, - url: file.uri, - fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', - file, - filename: file.name, - }; + return { state: 'remote', + fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', + filename: file.name + , url: file.uri }; } const path = this.internalStorageService.resolvePath(key); -- 2.45.2