Prefix all calls to Image and Video Processing service
Some checks failed
Lint / pnpm_install (pull_request) Successful in 1m58s
Publish Docker image / Build (pull_request) Successful in 4m40s
Test (production install and build) / production (22.11.0) (pull_request) Successful in 1m3s
Test (backend) / unit (22.11.0) (pull_request) Successful in 8m32s
Publish Docker image / Build (push) Successful in 4m37s
Lint / pnpm_install (push) Successful in 1m39s
Test (backend) / e2e (22.11.0) (pull_request) Failing after 11m28s
Test (production install and build) / production (22.11.0) (push) Successful in 1m1s
Lint / lint (backend) (pull_request) Successful in 2m8s
Test (backend) / unit (22.11.0) (push) Successful in 7m57s
Lint / lint (frontend) (pull_request) Successful in 2m13s
Lint / lint (frontend-embed) (pull_request) Successful in 2m18s
Lint / lint (frontend-shared) (pull_request) Successful in 2m19s
Test (backend) / e2e (22.11.0) (push) Failing after 11m35s
Lint / lint (misskey-bubble-game) (pull_request) Successful in 2m35s
Lint / lint (misskey-js) (pull_request) Successful in 2m20s
Lint / lint (misskey-reversi) (pull_request) Successful in 2m34s
Lint / lint (sw) (pull_request) Successful in 2m36s
Lint / typecheck (backend) (pull_request) Successful in 2m19s
Lint / typecheck (misskey-js) (pull_request) Successful in 1m38s
Lint / typecheck (sw) (pull_request) Successful in 1m50s
Lint / lint (backend) (push) Successful in 2m40s
Lint / lint (frontend) (push) Successful in 2m38s
Lint / lint (frontend-embed) (push) Successful in 2m30s
Lint / lint (frontend-shared) (push) Successful in 2m26s
Lint / lint (misskey-bubble-game) (push) Successful in 2m36s
Lint / lint (misskey-js) (push) Successful in 2m36s
Lint / lint (misskey-reversi) (push) Successful in 2m26s
Lint / lint (sw) (push) Successful in 2m27s
Lint / typecheck (misskey-js) (push) Failing after 36s
Lint / typecheck (backend) (push) Successful in 2m22s
Lint / typecheck (sw) (push) Successful in 1m28s

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>
This commit is contained in:
ゆめ 2024-11-20 01:59:48 -06:00
parent 4ba0357d49
commit 95d3fb08f4
No known key found for this signature in database
6 changed files with 126 additions and 409 deletions

View file

@ -36,7 +36,7 @@ import { GlobalEventService } from './GlobalEventService.js';
import { HashtagService } from './HashtagService.js'; import { HashtagService } from './HashtagService.js';
import { HttpRequestService } from './HttpRequestService.js'; import { HttpRequestService } from './HttpRequestService.js';
import { IdService } from './IdService.js'; import { IdService } from './IdService.js';
import { ImageProcessingService } from './ImageProcessingService.js'; import { __YUME_PRIVATE_ImageProcessingService } from './ImageProcessingService.js';
import { InstanceActorService } from './InstanceActorService.js'; import { InstanceActorService } from './InstanceActorService.js';
import { InternalStorageService } from './InternalStorageService.js'; import { InternalStorageService } from './InternalStorageService.js';
import { MetaService } from './MetaService.js'; import { MetaService } from './MetaService.js';
@ -67,7 +67,7 @@ import { UserMutingService } from './UserMutingService.js';
import { UserRenoteMutingService } from './UserRenoteMutingService.js'; import { UserRenoteMutingService } from './UserRenoteMutingService.js';
import { UserSuspendService } from './UserSuspendService.js'; import { UserSuspendService } from './UserSuspendService.js';
import { UserAuthService } from './UserAuthService.js'; import { UserAuthService } from './UserAuthService.js';
import { VideoProcessingService } from './VideoProcessingService.js'; import { __YUME_PRIVATE_VideoProcessingService } from './VideoProcessingService.js';
import { UserWebhookService } from './UserWebhookService.js'; import { UserWebhookService } from './UserWebhookService.js';
import { ProxyAccountService } from './ProxyAccountService.js'; import { ProxyAccountService } from './ProxyAccountService.js';
import { UtilityService } from './UtilityService.js'; import { UtilityService } from './UtilityService.js';
@ -179,7 +179,7 @@ const $GlobalEventService: Provider = { provide: 'GlobalEventService', useExisti
const $HashtagService: Provider = { provide: 'HashtagService', useExisting: HashtagService }; const $HashtagService: Provider = { provide: 'HashtagService', useExisting: HashtagService };
const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService }; const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService };
const $IdService: Provider = { provide: 'IdService', useExisting: IdService }; 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 $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService };
const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService }; const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService };
const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService }; const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService };
@ -212,7 +212,7 @@ const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService',
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService }; const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; 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 $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService }; const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService }; const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
@ -330,7 +330,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
HashtagService, HashtagService,
HttpRequestService, HttpRequestService,
IdService, IdService,
ImageProcessingService, __YUME_PRIVATE_ImageProcessingService,
InstanceActorService, InstanceActorService,
InternalStorageService, InternalStorageService,
MetaService, MetaService,
@ -363,7 +363,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserSearchService, UserSearchService,
UserSuspendService, UserSuspendService,
UserAuthService, UserAuthService,
VideoProcessingService, __YUME_PRIVATE_VideoProcessingService,
UserWebhookService, UserWebhookService,
SystemWebhookService, SystemWebhookService,
WebhookTestService, WebhookTestService,
@ -625,7 +625,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
HashtagService, HashtagService,
HttpRequestService, HttpRequestService,
IdService, IdService,
ImageProcessingService, __YUME_PRIVATE_ImageProcessingService,
InstanceActorService, InstanceActorService,
InternalStorageService, InternalStorageService,
MetaService, MetaService,
@ -658,7 +658,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserSearchService, UserSearchService,
UserSuspendService, UserSuspendService,
UserAuthService, UserAuthService,
VideoProcessingService, __YUME_PRIVATE_VideoProcessingService,
UserWebhookService, UserWebhookService,
SystemWebhookService, SystemWebhookService,
WebhookTestService, WebhookTestService,

View file

@ -22,8 +22,8 @@ import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { contentDisposition } from '@/misc/content-disposition.js'; import { contentDisposition } from '@/misc/content-disposition.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { VideoProcessingService } from '@/core/VideoProcessingService.js'; import { __YUME_PRIVATE_VideoProcessingService } from '@/core/VideoProcessingService.js';
import { ImageProcessingService } from '@/core/ImageProcessingService.js'; import { __YUME_PRIVATE_ImageProcessingService } from '@/core/ImageProcessingService.js';
import type { IImage } from '@/core/ImageProcessingService.js'; import type { IImage } from '@/core/ImageProcessingService.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import type { MiDriveFolder } from '@/models/DriveFolder.js'; import type { MiDriveFolder } from '@/models/DriveFolder.js';
@ -120,8 +120,8 @@ export class DriveService {
private downloadService: DownloadService, private downloadService: DownloadService,
private internalStorageService: InternalStorageService, private internalStorageService: InternalStorageService,
private s3Service: S3Service, private s3Service: S3Service,
private imageProcessingService: ImageProcessingService, private privateImageProcessingService: __YUME_PRIVATE_ImageProcessingService,
private videoProcessingService: VideoProcessingService, private privateVideoProcessingService: __YUME_PRIVATE_VideoProcessingService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private queueService: QueueService, private queueService: QueueService,
private roleService: RoleService, private roleService: RoleService,
@ -277,7 +277,7 @@ export class DriveService {
} }
try { try {
const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path); const thumbnail = await this.privateVideoProcessingService.generateVideoThumbnail(path);
return { return {
webpublic: null, webpublic: null,
thumbnail, thumbnail,
@ -331,9 +331,9 @@ export class DriveService {
try { try {
if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) { 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)) { } 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 { } else {
this.registerLogger.debug('web image not created (not an required image)'); this.registerLogger.debug('web image not created (not an required image)');
} }
@ -352,9 +352,9 @@ export class DriveService {
try { try {
if (isAnimated) { 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 { } else {
thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422); thumbnail = await this.privateImageProcessingService.convertSharpToWebp(img, 498, 422);
} }
} catch (err) { } catch (err) {
this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error); this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error);

View file

@ -46,7 +46,9 @@ import { bindThis } from '@/decorators.js';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
@Injectable() @Injectable()
export class ImageProcessingService { // Prevent accidental import by upstream merge
// eslint-disable-next-line
export class __YUME_PRIVATE_ImageProcessingService {
constructor( constructor(
) { ) {
} }

View file

@ -7,19 +7,21 @@ import { Inject, Injectable } from '@nestjs/common';
import FFmpeg from 'fluent-ffmpeg'; import FFmpeg from 'fluent-ffmpeg';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.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 type { IImage } from '@/core/ImageProcessingService.js';
import { createTempDir } from '@/misc/create-temp.js'; import { createTempDir } from '@/misc/create-temp.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { appendQuery, query } from '@/misc/prelude/url.js'; import { appendQuery, query } from '@/misc/prelude/url.js';
@Injectable() @Injectable()
export class VideoProcessingService { // Prevent accidental import by upstream merge
// eslint-disable-next-line
export class __YUME_PRIVATE_VideoProcessingService {
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
private imageProcessingService: ImageProcessingService, private imageProcessingService: __YUME_PRIVATE_ImageProcessingService,
) { ) {
} }

View file

@ -18,7 +18,6 @@ import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js'; import { isMimeImage } from '@/misc/is-mime-image.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { UtilityService } from '../UtilityService.js'; import { UtilityService } from '../UtilityService.js';
import { VideoProcessingService } from '../VideoProcessingService.js';
import { UserEntityService } from './UserEntityService.js'; import { UserEntityService } from './UserEntityService.js';
import { DriveFolderEntityService } from './DriveFolderEntityService.js'; import { DriveFolderEntityService } from './DriveFolderEntityService.js';
@ -43,7 +42,6 @@ export class DriveFileEntityService {
private utilityService: UtilityService, private utilityService: UtilityService,
private driveFolderEntityService: DriveFolderEntityService, private driveFolderEntityService: DriveFolderEntityService,
private videoProcessingService: VideoProcessingService,
private idService: IdService, private idService: IdService,
) { ) {
} }
@ -86,11 +84,7 @@ export class DriveFileEntityService {
@bindThis @bindThis
public getThumbnailUrl(file: MiDriveFile): string | null { public getThumbnailUrl(file: MiDriveFile): string | null {
if (file.type.startsWith('video')) { if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) {
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) {
// 動画ではなくリモートかつメディアプロキシ // 動画ではなくリモートかつメディアプロキシ
return this.getProxiedUrl(file.uri, 'static'); return this.getProxiedUrl(file.uri, 'static');
} }

View file

@ -8,27 +8,19 @@ import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import rename from 'rename'; import rename from 'rename';
import sharp from 'sharp';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js'; import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { createTemp } from '@/misc/create-temp.js';
import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import type Logger from '@/logger.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 { contentDisposition } from '@/misc/content-disposition.js';
import { FileInfoService } from '@/core/FileInfoService.js'; import { FileInfoService } from '@/core/FileInfoService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.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 { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
import { InternalStorageService } from '@/core/InternalStorageService.js';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
@ -46,11 +38,8 @@ export class FileServerService {
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
private fileInfoService: FileInfoService,
private downloadService: DownloadService,
private imageProcessingService: ImageProcessingService,
private videoProcessingService: VideoProcessingService,
private internalStorageService: InternalStorageService, private internalStorageService: InternalStorageService,
private fileInfoService: FileInfoService,
private loggerService: LoggerService, private loggerService: LoggerService,
) { ) {
this.logger = this.loggerService.getLogger('server', 'gray'); this.logger = this.loggerService.getLogger('server', 'gray');
@ -134,165 +123,78 @@ export class FileServerService {
return; return;
} }
try { if (file.state === 'remote') {
if (file.state === 'remote') { const url = new URL(`${this.config.mediaProxy}/`);
let image: IImageStreamable | null = null;
if (file.fileRole === 'thumbnail') { url.searchParams.set('url', file.url);
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`); return await reply.redirect(url.toString(), 301);
url.searchParams.set('url', file.url); }
url.searchParams.set('static', '1');
file.cleanup(); if (file.fileRole !== 'original') {
return await reply.redirect(url.toString(), 301); const filename = rename(file.filename, {
} else if (file.mime.startsWith('video/')) { suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web',
const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); extname: file.ext ? `.${file.ext}` : '.unknown',
if (externalThumbnail) { }).toString();
file.cleanup();
return await reply.redirect(externalThumbnail, 301);
}
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;
} }
const chunksize = end - start + 1;
if (file.fileRole === 'webpublic') { const fileStream = fs.createReadStream(file.path, {
if (['image/svg+xml'].includes(file.mime)) { start,
reply.header('Cache-Control', 'max-age=31536000, immutable'); end,
});
const url = new URL(`${this.config.mediaProxy}/svg.webp`); reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
url.searchParams.set('url', file.url); reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
file.cleanup(); reply.code(206);
return await reply.redirect(url.toString(), 301); return fileStream;
}
}
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;
} }
if (file.fileRole !== 'original') { return fs.createReadStream(file.path);
const filename = rename(file.filename, { } else {
suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
extname: file.ext ? `.${file.ext}` : '.unknown', reply.header('Content-Length', file.file.size);
}).toString(); 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'); if (request.headers.range && file.file.size > 0) {
reply.header('Cache-Control', 'max-age=31536000, immutable'); const range = request.headers.range as string;
reply.header('Content-Disposition', contentDisposition('inline', filename)); const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
if (request.headers.range && file.file.size > 0) { let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
const range = request.headers.range as string; if (end > file.file.size) {
const parts = range.replace(/bytes=/, '').split('-'); end = file.file.size - 1;
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;
} }
const chunksize = end - start + 1;
return fs.createReadStream(file.path); const fileStream = fs.createReadStream(file.path, {
} else { start,
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); end,
reply.header('Content-Length', file.file.size); });
reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Content-Disposition', contentDisposition('inline', file.filename)); reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
if (request.headers.range && file.file.size > 0) { reply.code(206);
const range = request.headers.range as string; return fileStream;
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);
} }
} catch (e) {
if ('cleanup' in file) file.cleanup(); return fs.createReadStream(file.path);
throw e;
} }
} }
@bindThis @bindThis
private async proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) { 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') { if (typeof url !== 'string') {
reply.code(400); reply.code(400);
@ -302,234 +204,56 @@ export class FileServerService {
// アバタークロップなど、どうしてもオリジンである必要がある場合 // アバタークロップなど、どうしてもオリジンである必要がある場合
const mustOrigin = 'origin' in request.query; const mustOrigin = 'origin' in request.query;
if (this.config.externalMediaProxyEnabled) { if (!this.config.mediaProxy) {
// 外部のメディアプロキシが有効なら、そちらにリダイレクト reply.code(501);
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 (!request.headers['user-agent']) { const proxiedURL = new URL(`${this.config.mediaProxy}/?url=${encodeURIComponent(url)}`);
throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { for (const [key, value] of Object.entries(request.query)) {
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); if (key.toLowerCase() === 'url') continue;
proxiedURL.searchParams.append(key, value);
} }
if (!request.headers['user-agent']) { if (!mustOrigin) {
throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); return await reply.redirect(
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { proxiedURL.toString(),
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); 301,
}
// 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),
),
); );
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 if (!request.headers['user-agent']) {
private async downloadAndDetectTypeFromUrl(url: string): Promise< throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
{ state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } } 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 [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');
}
// 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 @bindThis
private async getFileFromKey(key: string): Promise< 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; } | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404' | '404'
| '204' | '204'
@ -548,15 +272,10 @@ export class FileServerService {
if (!file.storedInternal) { if (!file.storedInternal) {
if (!(file.isLink && file.uri)) return '204'; if (!(file.isLink && file.uri)) return '204';
const result = await this.downloadAndDetectTypeFromUrl(file.uri); return { state: 'remote',
file.size = (await fs.promises.stat(result.path)).size; // DB file.sizeは正確とは限らないので fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
return { filename: file.name
...result, , url: file.uri };
url: file.uri,
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
file,
filename: file.name,
};
} }
const path = this.internalStorageService.resolvePath(key); const path = this.internalStorageService.resolvePath(key);