From 1ac7c154d7922d59e255b2d6c5c4c27b1f1b242a Mon Sep 17 00:00:00 2001 From: futchitwo <74236683+futchitwo@users.noreply.github.com> Date: Sun, 12 Feb 2023 08:21:40 +0900 Subject: [PATCH 1/5] fix: pagenation (#9885) --- packages/frontend/src/components/MkPagination.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 04c8616c9a..224a42cdc2 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -21,7 +21,7 @@
- + {{ i18n.ts.loadMore }} From f28aea9e303af96978da5e27739b5fd8e23b8f86 Mon Sep 17 00:00:00 2001 From: momoirodouhu Date: Sun, 12 Feb 2023 08:22:42 +0900 Subject: [PATCH 2/5] add cors header to ActivityPubServerService.ts (#9888) * add cors header to ActivityPubServerService.ts * Update CHANGELOG.md --- CHANGELOG.md | 1 + packages/backend/src/server/ActivityPubServerService.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c11d186926..7d0fbd0a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ You should also include the user name that made the change. ### Improvements - アニメーションを少なくする設定の時、MkPageHeaderのタブアニメーションを無効化 +- Backend: activitypub情報がcorsでブロックされないようヘッダーを追加 ### Bugfixes - Client: ユーザーページでアクティビティを見ることができない問題を修正 diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 186d3822d8..5480395eeb 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -441,6 +441,14 @@ export class ActivityPubServerService { fastify.addContentTypeParser('application/activity+json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore')); fastify.addContentTypeParser('application/ld+json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore')); + fastify.addHook('onRequest', (request, reply, done) => { + reply.header('Access-Control-Allow-Headers', 'Accept'); + reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Access-Control-Expose-Headers', 'Vary'); + done(); + }); + //#region Routing // inbox (limit: 64kb) fastify.post('/inbox', { bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); From ac7e2ecb59e2ad8c29bca52fca4e4d9b316403c5 Mon Sep 17 00:00:00 2001 From: KOKO Date: Sun, 12 Feb 2023 08:23:14 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=E5=BA=83=E5=91=8A=E3=81=AEexpiresAt?= =?UTF-8?q?=E3=82=92LocalTZ=E5=88=86=E3=81=9A=E3=82=89=E3=81=97=E3=81=A6?= =?UTF-8?q?=E5=88=9D=E6=9C=9F=E5=8C=96=20(#9876)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 広告のexpiresAtをLocalTZ分ずらして初期化 * chore: 不要なインポートを削除 --- packages/frontend/src/pages/admin/ads.vue | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index 4d6f32f9a9..701ec31b65 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -60,11 +60,17 @@ import { definePageMetadata } from '@/scripts/page-metadata'; let ads: any[] = $ref([]); +// ISO形式はTZがUTCになってしまうので、TZ分ずらして時間を初期化 +const localTime = new Date(); +const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000; + os.api('admin/ad/list').then(adsResponse => { ads = adsResponse.map(r => { + const date = new Date(r.expiresAt); + date.setMilliseconds(date.getMilliseconds() - localTimeDiff); return { ...r, - expiresAt: new Date(r.expiresAt).toISOString().slice(0, 16), + expiresAt: date.toISOString().slice(0, 16), }; }); }); From 3c7e1ff92ef4100347ee2151c3edfc431853532b Mon Sep 17 00:00:00 2001 From: RyotaK <49341894+Ry0taK@users.noreply.github.com> Date: Sun, 12 Feb 2023 09:07:56 +0900 Subject: [PATCH 4/5] =?UTF-8?q?Dev=20Container=E3=81=AE=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=20(#9872)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Dev Containerの設定を追加 * テンプレート生成時に含まれていたコメントを削除 * 起動スクリプトを分割 JSONの中にベタ書きすると長くなるので * 改行 * Dev Containerの使用方法を追記 --- .devcontainer/Dockerfile | 1 + .devcontainer/devcontainer.json | 11 +++ .devcontainer/devcontainer.yml | 146 +++++++++++++++++++++++++++++++ .devcontainer/docker-compose.yml | 52 +++++++++++ .devcontainer/init.sh | 9 ++ .gitignore | 1 + CONTRIBUTING.md | 19 ++++ 7 files changed, 239 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/devcontainer.yml create mode 100644 .devcontainer/docker-compose.yml create mode 100755 .devcontainer/init.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000000..b6ebcf6ad3 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM mcr.microsoft.com/devcontainers/javascript-node:0-18 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..e92f9dff78 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,11 @@ +{ + "name": "Misskey", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "features": { + "ghcr.io/devcontainers-contrib/features/pnpm:2": {} + }, + "forwardPorts": [3000], + "postCreateCommand": ".devcontainer/init.sh" +} diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml new file mode 100644 index 0000000000..8a363a15dc --- /dev/null +++ b/.devcontainer/devcontainer.yml @@ -0,0 +1,146 @@ +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Misskey configuration +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +# ┌─────┐ +#───┘ URL └───────────────────────────────────────────────────── + +# Final accessible URL seen by a user. +url: http://127.0.0.1:3000/ + +# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE +# URL SETTINGS AFTER THAT! + +# ┌───────────────────────┐ +#───┘ Port and TLS settings └─────────────────────────────────── + +# +# Misskey requires a reverse proxy to support HTTPS connections. +# +# +----- https://example.tld/ ------------+ +# +------+ |+-------------+ +----------------+| +# | User | ---> || Proxy (443) | ---> | Misskey (3000) || +# +------+ |+-------------+ +----------------+| +# +---------------------------------------+ +# +# You need to set up a reverse proxy. (e.g. nginx) +# An encrypted connection with HTTPS is highly recommended +# because tokens may be transferred in GET requests. + +# The port that your Misskey server should listen on. +port: 3000 + +# ┌──────────────────────────┐ +#───┘ PostgreSQL configuration └──────────────────────────────── + +db: + host: db + port: 5432 + + # Database name + db: misskey + + # Auth + user: postgres + pass: postgres + + # Whether disable Caching queries + #disableCache: true + + # Extra Connection options + #extra: + # ssl: true + +# ┌─────────────────────┐ +#───┘ Redis configuration └───────────────────────────────────── + +redis: + host: redis + port: 6379 + #family: 0 # 0=Both, 4=IPv4, 6=IPv6 + #pass: example-pass + #prefix: example-prefix + #db: 1 + +# ┌─────────────────────────────┐ +#───┘ Elasticsearch configuration └───────────────────────────── + +#elasticsearch: +# host: localhost +# port: 9200 +# ssl: false +# user: +# pass: + +# ┌───────────────┐ +#───┘ ID generation └─────────────────────────────────────────── + +# You can select the ID generation method. +# You don't usually need to change this setting, but you can +# change it according to your preferences. + +# Available methods: +# aid ... Short, Millisecond accuracy +# meid ... Similar to ObjectID, Millisecond accuracy +# ulid ... Millisecond accuracy +# objectid ... This is left for backward compatibility + +# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE +# ID SETTINGS AFTER THAT! + +id: 'aid' + +# ┌─────────────────────┐ +#───┘ Other configuration └───────────────────────────────────── + +# Whether disable HSTS +#disableHsts: true + +# Number of worker processes +#clusterLimit: 1 + +# Job concurrency per worker +# deliverJobConcurrency: 128 +# inboxJobConcurrency: 16 + +# Job rate limiter +# deliverJobPerSec: 128 +# inboxJobPerSec: 16 + +# Job attempts +# deliverJobMaxAttempts: 12 +# inboxJobMaxAttempts: 8 + +# IP address family used for outgoing request (ipv4, ipv6 or dual) +#outgoingAddressFamily: ipv4 + +# Proxy for HTTP/HTTPS +#proxy: http://127.0.0.1:3128 + +proxyBypassHosts: + - api.deepl.com + - api-free.deepl.com + - www.recaptcha.net + - hcaptcha.com + - challenges.cloudflare.com + +# Proxy for SMTP/SMTPS +#proxySmtp: http://127.0.0.1:3128 # use HTTP/1.1 CONNECT +#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 +#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 + +# Media Proxy +#mediaProxy: https://example.com/proxy + +# Proxy remote files (default: false) +#proxyRemoteFiles: true + +# Sign to ActivityPub GET request (default: true) +signToActivityPubGet: true + +allowedPrivateNetworks: [ + '127.0.0.1/32' +] + +# Upload or download file size limits (bytes) +#maxFileSize: 262144000 diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000000..6cb21844ac --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,52 @@ +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + + volumes: + - ../..:/workspaces:cached + + command: sleep infinity + + networks: + - internal_network + - external_network + + redis: + restart: always + image: redis:7-alpine + networks: + - internal_network + volumes: + - ../redis:/data + healthcheck: + test: "redis-cli ping" + interval: 5s + retries: 20 + + db: + restart: unless-stopped + image: postgres:15-alpine + networks: + - internal_network + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: misskey + volumes: + - ../db:/var/lib/postgresql/data + healthcheck: + test: "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" + interval: 5s + retries: 20 + +volumes: + postgres-data: + +networks: + internal_network: + internal: true + external_network: diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh new file mode 100755 index 0000000000..552b229fa5 --- /dev/null +++ b/.devcontainer/init.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -xe + +git submodule update --init +pnpm install --frozen-lockfile +cp .devcontainer/devcontainer.yml .config/default.yml +pnpm build +pnpm migrate diff --git a/.gitignore b/.gitignore index f532cdaa7e..62b818c629 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ coverage !/.config/docker_example.yml !/.config/docker_example.env docker-compose.yml +!/.devcontainer/docker-compose.yml # misskey /build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e539926789..de0a1abb45 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,6 +111,25 @@ command. - Vite HMR (just the `vite` command) is available. The behavior may be different from production. - Service Worker is watched by esbuild. +### Dev Container +Instead of running `pnpm` locally, you can use Dev Container to set up your development environment. +To use Dev Container, open the project directory on VSCode with Dev Containers installed. + +It will run the following command automatically inside the container. +``` bash +git submodule update --init +pnpm install --frozen-lockfile +cp .devcontainer/devcontainer.yml .config/default.yml +pnpm build +pnpm migrate +``` + +After finishing the migration, run the `pnpm dev` command to start the development server. + +``` bash +pnpm dev +``` + ## Testing - Test codes are located in [`/packages/backend/test`](/packages/backend/test). From ee03ab8d2c06254480e8b78d3c4eab4a78409ad5 Mon Sep 17 00:00:00 2001 From: tamaina Date: Sun, 12 Feb 2023 09:13:47 +0900 Subject: [PATCH 5/5] enhance(server): videoThumbnailGenerator config (#9845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance(server): videoThumbnailGenerator config * :v: * fix * 相対url * サムネイルのproxyRemoteFilesは直接プロキシを指定する * メディアプロキシ --- .config/example.yml | 9 ++++ packages/backend/src/config.ts | 6 +++ packages/backend/src/core/DriveService.ts | 8 +++ .../src/core/VideoProcessingService.ts | 14 +++++ .../core/entities/DriveFileEntityService.ts | 53 ++++++++++++++----- .../backend/src/server/FileServerService.ts | 6 +++ 6 files changed, 82 insertions(+), 14 deletions(-) diff --git a/.config/example.yml b/.config/example.yml index a19b5d04e8..92b8726623 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -131,11 +131,20 @@ proxyBypassHosts: # Media Proxy # Reference Implementation: https://github.com/misskey-dev/media-proxy +# * Deliver a common cache between instances +# * Perform image compression (on a different server resource than the main process) #mediaProxy: https://example.com/proxy # Proxy remote files (default: false) +# Proxy remote files by this instance or mediaProxy to prevent remote files from running in remote domains. #proxyRemoteFiles: true +# Movie Thumbnail Generation URL +# There is no reference implementation. +# For example, Misskey will point to the following URL: +# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4 +#videoThumbnailGenerator: https://example.com + # Sign to ActivityPub GET request (default: true) signToActivityPubGet: true diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index aa98ef1d22..73f45e92e1 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -67,6 +67,7 @@ export type Source = { mediaProxy?: string; proxyRemoteFiles?: boolean; + videoThumbnailGenerator?: string; signToActivityPubGet?: boolean; }; @@ -89,6 +90,7 @@ export type Mixin = { clientManifestExists: boolean; mediaProxy: string; externalMediaProxyEnabled: boolean; + videoThumbnailGenerator: string | null; }; export type Config = Source & Mixin; @@ -144,6 +146,10 @@ export function loadConfig() { mixin.mediaProxy = externalMediaProxy ?? internalMediaProxy; mixin.externalMediaProxyEnabled = externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy; + mixin.videoThumbnailGenerator = config.videoThumbnailGenerator ? + config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator + : null; + if (!config.redis.prefix) config.redis.prefix = mixin.host; return Object.assign(config, mixin); diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 598a457e83..42a430ea75 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -250,6 +250,14 @@ export class DriveService { @bindThis public async generateAlts(path: string, type: string, generateWeb: boolean) { if (type.startsWith('video/')) { + if (this.config.videoThumbnailGenerator != null) { + // videoThumbnailGeneratorが指定されていたら動画サムネイル生成はスキップ + return { + webpublic: null, + thumbnail: null, + } + } + try { const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path); return { diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts index ea5701decc..dd6c51c217 100644 --- a/packages/backend/src/core/VideoProcessingService.ts +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -6,6 +6,7 @@ import { 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 { @@ -41,5 +42,18 @@ export class VideoProcessingService { cleanup(); } } + + @bindThis + public getExternalVideoThumbnailUrl(url: string): string | null { + if (this.config.videoThumbnailGenerator == null) return null; + + return appendQuery( + `${this.config.videoThumbnailGenerator}/thumbnail.webp`, + query({ + thumbnail: '1', + url, + }) + ) + } } diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 9dd115d45a..b8550cd73e 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -13,6 +13,7 @@ import { deepClone } from '@/misc/clone.js'; import { UtilityService } from '../UtilityService.js'; import { UserEntityService } from './UserEntityService.js'; import { DriveFolderEntityService } from './DriveFolderEntityService.js'; +import { VideoProcessingService } from '../VideoProcessingService.js'; type PackOptions = { detail?: boolean, @@ -43,6 +44,7 @@ export class DriveFileEntityService { private utilityService: UtilityService, private driveFolderEntityService: DriveFolderEntityService, + private videoProcessingService: VideoProcessingService, ) { } @@ -72,40 +74,63 @@ export class DriveFileEntityService { } @bindThis - public getPublicUrl(file: DriveFile, mode? : 'static' | 'avatar'): string | null { // static = thumbnail - const proxiedUrl = (url: string) => appendQuery( + private getProxiedUrl(url: string, mode?: 'static' | 'avatar'): string | null { + return appendQuery( `${this.config.mediaProxy}/${mode ?? 'image'}.webp`, query({ url, ...(mode ? { [mode]: '1' } : {}), }) - ); + ) + } + @bindThis + public getThumbnailUrl(file: DriveFile): string | null { + if (file.type.startsWith('video')) { + if (file.thumbnailUrl) return file.thumbnailUrl; + + if (this.config.videoThumbnailGenerator == null) { + return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri); + } + } else if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { + // 動画ではなくリモートかつメディアプロキシ + return this.getProxiedUrl(file.uri, 'static'); + } + + if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) { + // リモートかつ期限切れはローカルプロキシを試みる + // 従来は/files/${thumbnailAccessKey}にアクセスしていたが、 + // /filesはメディアプロキシにリダイレクトするようにしたため直接メディアプロキシを指定する + return this.getProxiedUrl(file.uri, 'static'); + } + + const url = file.webpublicUrl ?? file.url; + + return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? this.getProxiedUrl(url, 'static') : null); + } + + @bindThis + public getPublicUrl(file: DriveFile, mode?: 'avatar'): string | null { // static = thumbnail // リモートかつメディアプロキシ if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { - if (!(mode === 'static' && file.type.startsWith('video'))) { - return proxiedUrl(file.uri); - } + return this.getProxiedUrl(file.uri, mode); } // リモートかつ期限切れはローカルプロキシを試みる if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) { - const key = mode === 'static' ? file.thumbnailAccessKey : file.webpublicAccessKey; + const key = file.webpublicAccessKey; if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外 const url = `${this.config.url}/files/${key}`; - if (mode === 'avatar') return proxiedUrl(file.uri); + if (mode === 'avatar') return this.getProxiedUrl(file.uri, 'avatar'); return url; } } const url = file.webpublicUrl ?? file.url; - if (mode === 'static') { - return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? proxiedUrl(url) : null); - } if (mode === 'avatar') { - return proxiedUrl(url); + return this.getProxiedUrl(url, 'avatar'); } return url; } @@ -183,7 +208,7 @@ export class DriveFileEntityService { blurhash: file.blurhash, properties: opts.self ? file.properties : this.getPublicProperties(file), url: opts.self ? file.url : this.getPublicUrl(file), - thumbnailUrl: this.getPublicUrl(file, 'static'), + thumbnailUrl: this.getThumbnailUrl(file), comment: file.comment, folderId: file.folderId, folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { @@ -218,7 +243,7 @@ export class DriveFileEntityService { blurhash: file.blurhash, properties: opts.self ? file.properties : this.getPublicProperties(file), url: opts.self ? file.url : this.getPublicUrl(file), - thumbnailUrl: this.getPublicUrl(file, 'static'), + thumbnailUrl: this.getThumbnailUrl(file), comment: file.comment, folderId: file.folderId, folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 49ded6c28e..f4bc568fdc 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -150,6 +150,12 @@ export class FileServerService { file.cleanup(); return await reply.redirect(301, url.toString()); } else if (file.mime.startsWith('video/')) { + const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); + if (externalThumbnail) { + file.cleanup(); + return await reply.redirect(301, externalThumbnail); + } + image = await this.videoProcessingService.generateVideoThumbnail(file.path); } }