Security: Prefix all calls to Image and Video Processing service #34
13 changed files with 206 additions and 428 deletions
16
packages/backend/migration/1732071810971-IndexUserDeleted.js
Normal file
16
packages/backend/migration/1732071810971-IndexUserDeleted.js
Normal file
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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配信
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(
|
||||
) {
|
||||
}
|
||||
|
|
|
@ -488,6 +488,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
return ids.length > 0
|
||||
? await this.usersRepository.findBy({
|
||||
id: In(ids),
|
||||
isDeleted: false,
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
|
@ -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}`;
|
||||
|
|
|
@ -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<void> {
|
||||
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;
|
||||
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -106,6 +106,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
id: In(ps.userIds),
|
||||
} : {
|
||||
id: In(ps.userIds),
|
||||
isDeleted: false,
|
||||
isSuspended: false,
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue