diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 296237c95..ddbcfb558 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -163,7 +163,7 @@ redis: # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── -# You can set scope to local (default value) or global +# You can set scope to local (default value) or global # (include notes from remote). #meilisearch: @@ -255,13 +255,18 @@ proxyRemoteFiles: true # https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4 #videoThumbnailGenerator: https://example.com +# Enables the built-in thumbnail generator for remote videos. (default: false) +# Only useful if "Cache remote files" is disabled, and "videoThumbnailGenerator" is unset. +# Without it, remote video files that are not cached will not have any thumbnails. +#enableBuiltinVideoThumbnailGenerator: false + # Sign to ActivityPub GET request (default: true) signToActivityPubGet: true # check that inbound ActivityPub GET requests are signed ("authorized fetch") checkActivityPubGetSignature: false # For security reasons, uploading attachments from the intranet is prohibited, -# but exceptions can be made from the following settings. Default value is "undefined". +# but exceptions can be made from the following settings. Default value is "undefined". # Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)). #allowedPrivateNetworks: [ # '127.0.0.1/32' diff --git a/.config/example.yml b/.config/example.yml index c037a280b..afa8d0cf3 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -270,6 +270,11 @@ proxyRemoteFiles: true # https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4 #videoThumbnailGenerator: https://example.com +# Enables the built-in thumbnail generator for remote videos. (default: false) +# Only useful if "Cache remote files" is disabled, and "videoThumbnailGenerator" is unset. +# Without it, remote video files that are not cached will not have any thumbnails. +#enableBuiltinVideoThumbnailGenerator: false + # Sign to ActivityPub GET request (default: true) signToActivityPubGet: true # check that inbound ActivityPub GET requests are signed ("authorized fetch") diff --git a/.gitignore b/.gitignore index 2b6a5c1eb..7850eeee1 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ api-docs.json *.code-workspace .DS_Store /files +/cache ormconfig.json temp /packages/frontend/src/**/*.stories.ts diff --git a/docker-compose_example.yml b/docker-compose_example.yml index 647f6f0c7..e5b7d1d7a 100644 --- a/docker-compose_example.yml +++ b/docker-compose_example.yml @@ -21,6 +21,7 @@ services: - shonk volumes: - ./files:/sharkey/files + - ./cache:/sharkey/cache - ./.config:/sharkey/.config:ro redis: diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index c99bc7ae0..a64e3e9ca 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -88,6 +88,7 @@ type Source = { mediaProxy?: string; proxyRemoteFiles?: boolean; videoThumbnailGenerator?: string; + enableBuiltinVideoThumbnailGenerator?: boolean; customMOTD?: string[]; @@ -170,6 +171,7 @@ export type Config = { mediaProxy: string; externalMediaProxyEnabled: boolean; videoThumbnailGenerator: string | null; + enableBuiltinVideoThumbnailGenerator: boolean; redis: RedisOptions & RedisOptionsSource; redisForPubsub: RedisOptions & RedisOptionsSource; redisForJobQueue: RedisOptions & RedisOptionsSource; @@ -276,6 +278,7 @@ export function loadConfig(): Config { videoThumbnailGenerator: config.videoThumbnailGenerator ? config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator : null, + enableBuiltinVideoThumbnailGenerator: config.enableBuiltinVideoThumbnailGenerator ?? false, userAgent: `Misskey/${version} (${config.url})`, clientEntry: clientManifest['src/_boot_.ts'], clientManifestExists: clientManifestExists, diff --git a/packages/backend/src/core/InternalStorageService.ts b/packages/backend/src/core/InternalStorageService.ts index 4fb8a93e4..22b8460d3 100644 --- a/packages/backend/src/core/InternalStorageService.ts +++ b/packages/backend/src/core/InternalStorageService.ts @@ -16,6 +16,7 @@ const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); const path = Path.resolve(_dirname, '../../../../files'); +const cachePath = Path.resolve(_dirname, '../../../../cache'); @Injectable() export class InternalStorageService { @@ -30,11 +31,26 @@ export class InternalStorageService { return Path.resolve(path, key); } + @bindThis + public resolveCachePath(key: string) { + return Path.resolve(cachePath, key); + } + + @bindThis + public existsCache(key: string) { + return fs.existsSync(this.resolveCachePath(key)); + } + @bindThis public read(key: string) { return fs.createReadStream(this.resolvePath(key)); } + @bindThis + public readCache(key: string) { + return fs.createReadStream(this.resolveCachePath(key)); + } + @bindThis public saveFromPath(key: string, srcPath: string) { fs.mkdirSync(path, { recursive: true }); @@ -49,8 +65,19 @@ export class InternalStorageService { return `${this.config.url}/files/${key}`; } + @bindThis + public saveCacheFromBuffer(key: string, data: Buffer) { + fs.mkdirSync(cachePath, { recursive: true }); + fs.writeFileSync(this.resolveCachePath(key), data); + } + @bindThis public del(key: string) { fs.unlink(this.resolvePath(key), () => {}); } + + @bindThis + public delCache(key: string) { + fs.unlink(this.resolveCachePath(key), () => {}); + } } diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts index 747fe4fc7..3773bf566 100644 --- a/packages/backend/src/core/VideoProcessingService.ts +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -50,7 +50,16 @@ export class VideoProcessingService { @bindThis public getExternalVideoThumbnailUrl(url: string): string | null { - if (this.config.videoThumbnailGenerator == null) return null; + if (this.config.videoThumbnailGenerator == null) { + if (this.config.enableBuiltinVideoThumbnailGenerator) { + return appendQuery( + `${this.config.url}/proxy/thumbnail.webp`, + query({ url }), + ); + } + + return null; + } return appendQuery( `${this.config.videoThumbnailGenerator}/thumbnail.webp`, diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 6d24898ac..258b3e216 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -4,6 +4,7 @@ */ import * as fs from 'node:fs'; +import * as crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; @@ -34,6 +35,7 @@ const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); const assets = `${_dirname}/../../server/file/assets/`; +const cacheDir = `${_dirname}/../../../../cache/`; @Injectable() export class FileServerService { @@ -85,6 +87,15 @@ export class FileServerService { done(); }); + if (this.config.enableBuiltinVideoThumbnailGenerator) { + fastify.get<{ + Querystring: { url: string; }; + }>('/proxy/thumbnail.webp', async (request, reply) => { + return await this.videoThumbnailHandler(request, reply) + .catch(err => this.errorHandler(request, reply, err)); + }); + } + fastify.get<{ Params: { url: string; }; Querystring: { url?: string; }; @@ -466,6 +477,61 @@ export class FileServerService { } } + @bindThis + private async videoThumbnailHandler(request: FastifyRequest<{ Querystring: { url: string; }; }>, reply: FastifyReply) { + const cacheKey = crypto.createHash('md5').update(request.query.url).digest('base64url'); + const cacheFile = `videoThumbnail-${cacheKey}.webp`; + if (this.internalStorageService.existsCache(cacheFile)) { + reply.header('Content-Type', 'image/webp'); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + return reply.sendFile(cacheFile, cacheDir); + } + + const file = await this.getStreamAndTypeFromUrl(request.query.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; + } + + if (file.file?.thumbnailUrl) { + return await reply.redirect(301, file.file.thumbnailUrl); + } + + if (!file.mime.startsWith('video/')) { + if ('cleanup' in file) { + file.cleanup(); + } + + reply.code(400); + return; + } + + try { + const image = await this.videoProcessingService.generateVideoThumbnail(file.path); + + if ('cleanup' in file) { + file.cleanup(); + } + + this.internalStorageService.saveCacheFromBuffer(cacheFile, image.data); + + reply.header('Content-Type', image.type); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + 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; }