From f014b7ae0ece886ef0cff2366b9925e23b34ba6f Mon Sep 17 00:00:00 2001 From: MeiMei <30769358+mei23@users.noreply.github.com> Date: Tue, 5 Feb 2019 03:01:36 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=A2=E3=83=8B=E3=83=A1=E3=83=BC=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=82=92=E8=87=AA=E5=8B=95=E5=86=8D=E7=94=9F?= =?UTF-8?q?=E3=81=97=E3=81=AA=E3=81=84=E3=82=AA=E3=83=97=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=20(#4131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor * settings * Media Proxy * Replace API response --- locales/ja-JP.yml | 1 + .../app/desktop/views/components/settings.vue | 6 + .../app/mobile/views/pages/settings.vue | 6 + src/misc/wrap-url.ts | 20 ++ src/models/drive-file.ts | 10 +- src/models/note.ts | 10 +- src/models/user.ts | 10 +- src/server/api/endpoints/drive/stream.ts | 2 +- src/server/index.ts | 1 + src/server/proxy/index.ts | 22 ++ src/server/proxy/proxy-media.ts | 113 +++++++ src/services/drive/add-file.ts | 283 ++++++++---------- .../drive/generate-video-thumbnail.ts | 15 +- src/services/drive/image-processor.ts | 75 +++++ 14 files changed, 404 insertions(+), 170 deletions(-) create mode 100644 src/misc/wrap-url.ts create mode 100644 src/server/proxy/index.ts create mode 100644 src/server/proxy/proxy-media.ts create mode 100644 src/services/drive/image-processor.ts diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 24eac18c3a..92f27a2bf0 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -121,6 +121,7 @@ common: use-avatar-reversi-stones: "リバーシの石にアバターを使う" verified-user: "公式アカウント" disable-animated-mfm: "投稿内の動きのあるテキストを無効にする" + do-not-autoplay-animation: "アニメーションを自動再生しない" suggest-recent-hashtags: "最近のハッシュタグを投稿フォームに表示する" always-show-nsfw: "常に閲覧注意のメディアを表示する" always-mark-nsfw: "常にメディアを閲覧注意として投稿" diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue index 95c107a0d5..8ab956830e 100644 --- a/src/client/app/desktop/views/components/settings.vue +++ b/src/client/app/desktop/views/components/settings.vue @@ -117,6 +117,7 @@ {{ $t('show-reply-target') }} {{ $t('show-maps') }} {{ $t('@.disable-animated-mfm') }} + {{ $t('@.do-not-autoplay-animation') }} {{ $t('remain-deleted-note') }}
@@ -516,6 +517,11 @@ export default Vue.extend({ set(value) { this.$store.dispatch('settings/set', { key: 'disableAnimatedMfm', value }); } }, + doNotAutoplayAnimation: { + get() { return !!this.$store.state.settings.doNotAutoplayAnimation; }, + set(value) { this.$store.dispatch('settings/set', { key: 'doNotAutoplayAnimation', value }); } + }, + remainDeletedNote: { get() { return this.$store.state.settings.remainDeletedNote; }, set(value) { this.$store.dispatch('settings/set', { key: 'remainDeletedNote', value }); } diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue index cc7eb1e300..f7ddefc5f0 100644 --- a/src/client/app/mobile/views/pages/settings.vue +++ b/src/client/app/mobile/views/pages/settings.vue @@ -29,6 +29,7 @@ {{ $t('@.use-os-default-emojis') }} {{ $t('@.i-like-sushi') }} {{ $t('@.disable-animated-mfm') }} + {{ $t('@.do-not-autoplay-animation') }} {{ $t('@.suggest-recent-hashtags') }} {{ $t('@.always-show-nsfw') }} ({{ $t('@.this-setting-is-this-device-only') }})
@@ -313,6 +314,11 @@ export default Vue.extend({ set(value) { this.$store.dispatch('settings/set', { key: 'disableAnimatedMfm', value }); } }, + doNotAutoplayAnimation: { + get() { return !!this.$store.state.settings.doNotAutoplayAnimation; }, + set(value) { this.$store.dispatch('settings/set', { key: 'doNotAutoplayAnimation', value }); } + }, + showReplyTarget: { get() { return this.$store.state.settings.showReplyTarget; }, set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); } diff --git a/src/misc/wrap-url.ts b/src/misc/wrap-url.ts new file mode 100644 index 0000000000..25fda4d96a --- /dev/null +++ b/src/misc/wrap-url.ts @@ -0,0 +1,20 @@ +import { URL } from 'url'; +import config from '../config'; + +/** + * avatar, thumbnail, custom-emoji 等のURLをクライアント設定等によって置き換える + */ +export default function(url: string, me: any) { + if (url == null) return url; + + // アニメーション再生無効 + if (me && me.clientSettings && me.clientSettings.doNotAutoplayAnimation) { + const u = new URL(url); + const dummy = `${u.host}${u.pathname}`; // 拡張子がないとキャッシュしてくれないCDNがあるので + let result = `${config.url}/proxy/${dummy}?url=${encodeURI(u.href)}`; + result += '&static=1'; + return result; + } + + return url; +} diff --git a/src/models/drive-file.ts b/src/models/drive-file.ts index 62a544c214..e788ac2b2f 100644 --- a/src/models/drive-file.ts +++ b/src/models/drive-file.ts @@ -1,10 +1,11 @@ import * as mongo from 'mongodb'; import * as deepcopy from 'deepcopy'; import { pack as packFolder } from './drive-folder'; -import { pack as packUser } from './user'; +import { pack as packUser, IUser } from './user'; import monkDb, { nativeDbConn, dbLogger } from '../db/mongodb'; import isObjectId from '../misc/is-objectid'; import getDriveFileUrl, { getOriginalUrl } from '../misc/get-drive-file-url'; +import wrapUrl from '../misc/wrap-url'; const DriveFile = monkDb.get('driveFiles.files'); DriveFile.createIndex('md5'); @@ -133,6 +134,7 @@ export const packMany = ( detail?: boolean self?: boolean, withUser?: boolean, + me?: string | mongo.ObjectID | IUser, } ) => { return Promise.all(files.map(f => pack(f, options))); @@ -147,6 +149,7 @@ export const pack = ( detail?: boolean, self?: boolean, withUser?: boolean, + me?: string | mongo.ObjectID | IUser, } ) => new Promise(async (resolve, reject) => { const opts = Object.assign({ @@ -189,6 +192,11 @@ export const pack = ( _target.url = getDriveFileUrl(_file); _target.thumbnailUrl = getDriveFileUrl(_file, true); + + if (_target.thumbnailUrl != null) { + _target.thumbnailUrl = wrapUrl(_target.thumbnailUrl, options.me); + } + _target.isRemote = _file.metadata.isRemote; if (_target.properties == null) _target.properties = {}; diff --git a/src/models/note.ts b/src/models/note.ts index 352de4f8d6..b1031d3e9b 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -11,6 +11,7 @@ import Reaction from './note-reaction'; import { packMany as packFileMany, IDriveFile } from './drive-file'; import Following from './following'; import Emoji from './emoji'; +import wrapUrl from '../misc/wrap-url'; const Note = db.get('notes'); Note.createIndex('uri', { sparse: true, unique: true }); @@ -247,11 +248,14 @@ export const pack = async ( fields: { _id: false } }); } else { - _note.emojis = Emoji.find({ + _note.emojis = (await Emoji.find({ name: { $in: _note.emojis }, host: host }, { fields: { _id: false } + })).map(emoji => async () => { + emoji.url = await wrapUrl(emoji.url, me); + return emoji; }); } } @@ -274,7 +278,7 @@ export const pack = async ( if (_note.geo) delete _note.geo.type; // Populate user - _note.user = packUser(_note.userId, meId); + _note.user = packUser(_note.userId, me); // Populate app if (_note.appId) { @@ -282,7 +286,7 @@ export const pack = async ( } // Populate files - _note.files = packFileMany(_note.fileIds || []); + _note.files = packFileMany(_note.fileIds || [], { me }); // Some counts _note.renoteCount = _note.renoteCount || 0; diff --git a/src/models/user.ts b/src/models/user.ts index 2453a2ed15..cba1d98c46 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -12,6 +12,7 @@ import config from '../config'; import FollowRequest from './follow-request'; import fetchMeta from '../misc/fetch-meta'; import Emoji from './emoji'; +import wrapUrl from '../misc/wrap-url'; const User = db.get('users'); @@ -344,6 +345,8 @@ export const pack = ( if (_user.avatarUrl == null) { _user.avatarUrl = `${config.drive_url}/default-avatar.jpg`; + } else { + _user.avatarUrl = wrapUrl(_user.avatarUrl, me); } if (!meId || !meId.equals(_user.id) || !opts.detail) { @@ -368,7 +371,7 @@ export const pack = ( if (opts.detail) { if (_user.pinnedNoteIds) { // Populate pinned notes - _user.pinnedNotes = packNoteMany(_user.pinnedNoteIds, meId, { + _user.pinnedNotes = packNoteMany(_user.pinnedNoteIds, me, { detail: true }); } @@ -397,11 +400,14 @@ export const pack = ( // カスタム絵文字添付 if (_user.emojis) { - _user.emojis = Emoji.find({ + _user.emojis = (await Emoji.find({ name: { $in: _user.emojis }, host: _user.host }, { fields: { _id: false } + })).map(emoji => { + emoji.url = wrapUrl(emoji.url, me); + return emoji; }); } diff --git a/src/server/api/endpoints/drive/stream.ts b/src/server/api/endpoints/drive/stream.ts index c8342c66b5..d364f62778 100644 --- a/src/server/api/endpoints/drive/stream.ts +++ b/src/server/api/endpoints/drive/stream.ts @@ -65,5 +65,5 @@ export default define(meta, (ps, user) => new Promise(async (res, rej) => { sort: sort }); - res(await packMany(files, { self: true })); + res(await packMany(files, { self: true, me: user })); })); diff --git a/src/server/index.ts b/src/server/index.ts index 26fa06d111..720a191d55 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -61,6 +61,7 @@ if (config.url.startsWith('https') && !config.disableHsts) { app.use(mount('/api', apiServer)); app.use(mount('/files', require('./file'))); +app.use(mount('/proxy', require('./proxy'))); // Init router const router = new Router(); diff --git a/src/server/proxy/index.ts b/src/server/proxy/index.ts new file mode 100644 index 0000000000..8d33af85da --- /dev/null +++ b/src/server/proxy/index.ts @@ -0,0 +1,22 @@ +/** + * Media Proxy + */ + +import * as Koa from 'koa'; +import * as cors from '@koa/cors'; +import * as Router from 'koa-router'; +import { proxyMedia } from './proxy-media'; + +// Init app +const app = new Koa(); +app.use(cors()); + +// Init router +const router = new Router(); + +router.get('/:url*', proxyMedia); + +// Register router +app.use(router.routes()); + +module.exports = app; diff --git a/src/server/proxy/proxy-media.ts b/src/server/proxy/proxy-media.ts new file mode 100644 index 0000000000..a0b65fbcfc --- /dev/null +++ b/src/server/proxy/proxy-media.ts @@ -0,0 +1,113 @@ +import * as fs from 'fs'; +import * as URL from 'url'; +import * as tmp from 'tmp'; +import * as Koa from 'koa'; +import * as request from 'request'; +import * as fileType from 'file-type'; +import * as isSvg from 'is-svg'; +import { serverLogger } from '..'; +import config from '../../config'; +import { IImage, ConvertToPng } from '../../services/drive/image-processor'; + +export async function proxyMedia(ctx: Koa.BaseContext) { + const url = 'url' in ctx.query ? ctx.query.url : 'https://' + ctx.params.url; + console.log(url); + + // Create temp file + const [path, cleanup] = await new Promise<[string, any]>((res, rej) => { + tmp.file((e, path, fd, cleanup) => { + if (e) return rej(e); + res([path, cleanup]); + }); + }); + + try { + await fetch(url, path); + + const [type, ext] = await detectMine(path); + + let image: IImage; + + if ('static' in ctx.query && ['image/png', 'image/gif'].includes(type)) { + image = await ConvertToPng(path, 498, 280); + } else { + image = { + data: fs.readFileSync(path), + ext, + type, + }; + } + + ctx.set('Content-Type', type); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + ctx.body = image.data; + } catch (e) { + serverLogger.error(e); + ctx.status = 500; + } finally { + cleanup(); + } +} + +async function fetch(url: string, path: string) { + await new Promise((res, rej) => { + const writable = fs.createWriteStream(path); + + writable.on('finish', () => { + res(); + }); + + writable.on('error', error => { + rej(error); + }); + + const requestUrl = URL.parse(url).pathname.match(/[^\u0021-\u00ff]/) ? encodeURI(url) : url; + + const req = request({ + url: requestUrl, + proxy: config.proxy, + timeout: 10 * 1000, + headers: { + 'User-Agent': config.user_agent + } + }); + + req.pipe(writable); + + req.on('response', response => { + if (response.statusCode !== 200) { + writable.close(); + rej(response.statusCode); + } + }); + + req.on('error', error => { + writable.close(); + rej(error); + }); + }); +} + +async function detectMine(path: string) { + return new Promise<[string, string]>((res, rej) => { + const readable = fs.createReadStream(path); + readable + .on('error', rej) + .once('data', (buffer: Buffer) => { + readable.destroy(); + const type = fileType(buffer); + if (type) { + res([type.mime, type.ext]); + } else if (isSvg(buffer)) { + res(['image/svg+xml', 'svg']); + } else { + // 種類が同定できなかったら application/octet-stream にする + res(['application/octet-stream', null]); + } + }) + .on('end', () => { + // maybe 0 bytes + res(['application/octet-stream', null]); + }); + }); +} diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts index b360df099b..2b3b923b93 100644 --- a/src/services/drive/add-file.ts +++ b/src/services/drive/add-file.ts @@ -23,6 +23,7 @@ import perUserDriveChart from '../../chart/per-user-drive'; import fetchMeta from '../../misc/fetch-meta'; import { GenerateVideoThumbnail } from './generate-video-thumbnail'; import { driveLogger } from './logger'; +import { IImage, ConvertToJpeg, ConvertToWebp, ConvertToPng } from './image-processor'; const logger = driveLogger.createSubLogger('register', 'yellow'); @@ -36,99 +37,11 @@ const logger = driveLogger.createSubLogger('register', 'yellow'); * @param metadata */ async function save(path: string, name: string, type: string, hash: string, size: number, metadata: IMetadata): Promise { - // #region webpublic - let webpublic: Buffer; - let webpublicExt = 'jpg'; - let webpublicType = 'image/jpeg'; - - if (!metadata.uri) { // from local instance - logger.info(`creating web image of ${name}`); - - if (['image/jpeg'].includes(type)) { - webpublic = await sharp(path) - .resize(2048, 2048, { - fit: 'inside', - withoutEnlargement: true - }) - .rotate() - .jpeg({ - quality: 85, - progressive: true - }) - .toBuffer(); - } else if (['image/webp'].includes(type)) { - webpublic = await sharp(path) - .resize(2048, 2048, { - fit: 'inside', - withoutEnlargement: true - }) - .rotate() - .webp({ - quality: 85 - }) - .toBuffer(); - - webpublicExt = 'webp'; - webpublicType = 'image/webp'; - } else if (['image/png'].includes(type)) { - webpublic = await sharp(path) - .resize(2048, 2048, { - fit: 'inside', - withoutEnlargement: true - }) - .rotate() - .png() - .toBuffer(); - - webpublicExt = 'png'; - webpublicType = 'image/png'; - } else { - logger.info(`web image not created (not an image)`); - } - } else { - logger.info(`web image not created (from remote)`); - } - // #endregion webpublic - - // #region thumbnail - let thumbnail: Buffer; - let thumbnailExt = 'jpg'; - let thumbnailType = 'image/jpeg'; - - if (['image/jpeg', 'image/webp'].includes(type)) { - thumbnail = await sharp(path) - .resize(498, 280, { - fit: 'inside', - withoutEnlargement: true - }) - .rotate() - .jpeg({ - quality: 85, - progressive: true - }) - .toBuffer(); - } else if (['image/png'].includes(type)) { - thumbnail = await sharp(path) - .resize(498, 280, { - fit: 'inside', - withoutEnlargement: true - }) - .rotate() - .png() - .toBuffer(); - - thumbnailExt = 'png'; - thumbnailType = 'image/png'; - } else if (type.startsWith('video/')) { - try { - thumbnail = await GenerateVideoThumbnail(path); - } catch (e) { - logger.error(`GenerateVideoThumbnail failed: ${e}`); - } - } - // #endregion thumbnail + // thunbnail, webpublic を必要なら生成 + const alts = await generateAlts(path, type, !metadata.uri); if (config.drive && config.drive.storage == 'minio') { + //#region ObjectStorage params let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) || ['']); if (ext === '') { @@ -137,41 +50,57 @@ async function save(path: string, name: string, type: string, hash: string, size if (type === 'image/webp') ext = '.webp'; } - const key = `${config.drive.prefix}/${uuid.v4()}${ext}`; - const webpublicKey = `${config.drive.prefix}/${uuid.v4()}.${webpublicExt}`; - const thumbnailKey = `${config.drive.prefix}/${uuid.v4()}.${thumbnailExt}`; + const baseUrl = config.drive.baseUrl + || `${ config.drive.config.useSSL ? 'https' : 'http' }://${ config.drive.config.endPoint }${ config.drive.config.port ? `:${config.drive.config.port}` : '' }/${ config.drive.bucket }`; + // for original + const key = `${config.drive.prefix}/${uuid.v4()}${ext}`; + const url = `${ baseUrl }/${ key }`; + + // for alts + let webpublicKey = null as string; + let webpublicUrl = null as string; + let thumbnailKey = null as string; + let thumbnailUrl = null as string; + //#endregion + + //#region Uploads logger.info(`uploading original: ${key}`); const uploads = [ upload(key, fs.createReadStream(path), type) ]; - if (webpublic) { + if (alts.webpublic) { + webpublicKey = `${config.drive.prefix}/${uuid.v4()}.${alts.webpublic.ext}`; + webpublicUrl = `${ baseUrl }/${ webpublicKey }`; + logger.info(`uploading webpublic: ${webpublicKey}`); - uploads.push(upload(webpublicKey, webpublic, webpublicType)); + uploads.push(upload(webpublicKey, alts.webpublic.data, alts.webpublic.type)); } - if (thumbnail) { + if (alts.thumbnail) { + thumbnailKey = `${config.drive.prefix}/${uuid.v4()}.${alts.thumbnail.ext}`; + thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; + logger.info(`uploading thumbnail: ${thumbnailKey}`); - uploads.push(upload(thumbnailKey, thumbnail, thumbnailType)); + uploads.push(upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); } await Promise.all(uploads); + //#endregion - const baseUrl = config.drive.baseUrl - || `${ config.drive.config.useSSL ? 'https' : 'http' }://${ config.drive.config.endPoint }${ config.drive.config.port ? `:${config.drive.config.port}` : '' }/${ config.drive.bucket }`; - + //#region DB Object.assign(metadata, { withoutChunks: true, storage: 'minio', storageProps: { - key: key, - webpublicKey: webpublic ? webpublicKey : null, - thumbnailKey: thumbnail ? thumbnailKey : null, + key, + webpublicKey, + thumbnailKey, }, - url: `${ baseUrl }/${ key }`, - webpublicUrl: webpublic ? `${ baseUrl }/${ webpublicKey }` : null, - thumbnailUrl: thumbnail ? `${ baseUrl }/${ thumbnailKey }` : null + url, + webpublicUrl, + thumbnailUrl, } as IMetadata); const file = await DriveFile.insert({ @@ -182,73 +111,91 @@ async function save(path: string, name: string, type: string, hash: string, size metadata: metadata, contentType: type }); + //#endregion return file; - } else { + } else { // use MongoDB GridFS // #region store original const originalDst = await getDriveFileBucket(); // web用(Exif削除済み)がある場合はオリジナルにアクセス制限 - if (webpublic) metadata.accessKey = uuid.v4(); + if (alts.webpublic) metadata.accessKey = uuid.v4(); - const originalFile = await new Promise((resolve, reject) => { - const writeStream = originalDst.openUploadStream(name, { - contentType: type, - metadata - }); - - writeStream.once('finish', resolve); - writeStream.on('error', reject); - fs.createReadStream(path).pipe(writeStream); - }); + const originalFile = await storeOriginal(originalDst, name, path, type, metadata); logger.info(`original stored to ${originalFile._id}`); // #endregion store original // #region store webpublic - if (webpublic) { + if (alts.webpublic) { const webDst = await getDriveFileWebpublicBucket(); - - const webFile = await new Promise((resolve, reject) => { - const writeStream = webDst.openUploadStream(name, { - contentType: webpublicType, - metadata: { - originalId: originalFile._id - } - }); - - writeStream.once('finish', resolve); - writeStream.on('error', reject); - writeStream.end(webpublic); - }); - + const webFile = await storeAlts(webDst, name, alts.webpublic.data, alts.webpublic.type, originalFile._id); logger.info(`web stored ${webFile._id}`); } // #endregion store webpublic - if (thumbnail) { - const thumbnailBucket = await getDriveFileThumbnailBucket(); - - const tuhmFile = await new Promise((resolve, reject) => { - const writeStream = thumbnailBucket.openUploadStream(name, { - contentType: thumbnailType, - metadata: { - originalId: originalFile._id - } - }); - - writeStream.once('finish', resolve); - writeStream.on('error', reject); - writeStream.end(thumbnail); - }); - - logger.info(`thumbnail stored ${tuhmFile._id}`); + if (alts.thumbnail) { + const thumDst = await getDriveFileThumbnailBucket(); + const thumFile = await storeAlts(thumDst, name, alts.thumbnail.data, alts.thumbnail.type, originalFile._id); + logger.info(`web stored ${thumFile._id}`); } return originalFile; } } +/** + * Generate webpublic, thumbnail, etc + * @param path Path for original + * @param type Content-Type for original + * @param generateWeb Generate webpublic or not + */ +export async function generateAlts(path: string, type: string, generateWeb: boolean) { + // #region webpublic + let webpublic: IImage; + + if (generateWeb) { + logger.info(`creating web image`); + + if (['image/jpeg'].includes(type)) { + webpublic = await ConvertToJpeg(path, 2048, 2048); + } else if (['image/webp'].includes(type)) { + webpublic = await ConvertToWebp(path, 2048, 2048); + } else if (['image/png'].includes(type)) { + webpublic = await ConvertToPng(path, 2048, 2048); + } else { + logger.info(`web image not created (not an image)`); + } + } else { + logger.info(`web image not created (from remote)`); + } + // #endregion webpublic + + // #region thumbnail + let thumbnail: IImage; + + if (['image/jpeg', 'image/webp'].includes(type)) { + thumbnail = await ConvertToJpeg(path, 498, 280); + } else if (['image/png'].includes(type)) { + thumbnail = await ConvertToPng(path, 498, 280); + } else if (type.startsWith('video/')) { + try { + thumbnail = await GenerateVideoThumbnail(path); + } catch (e) { + logger.error(`GenerateVideoThumbnail failed: ${e}`); + } + } + // #endregion thumbnail + + return { + webpublic, + thumbnail, + }; +} + +/** + * Upload to ObjectStorage + */ async function upload(key: string, stream: fs.ReadStream | Buffer, type: string) { const minio = new Minio.Client(config.drive.config); @@ -258,6 +205,40 @@ async function upload(key: string, stream: fs.ReadStream | Buffer, type: string) }); } +/** + * GridFSBucketにオリジナルを格納する + */ +export async function storeOriginal(bucket: mongodb.GridFSBucket, name: string, path: string, contentType: string, metadata: any) { + return new Promise((resolve, reject) => { + const writeStream = bucket.openUploadStream(name, { + contentType, + metadata + }); + + writeStream.once('finish', resolve); + writeStream.on('error', reject); + fs.createReadStream(path).pipe(writeStream); + }); +} + +/** + * GridFSBucketにオリジナル以外を格納する + */ +export async function storeAlts(bucket: mongodb.GridFSBucket, name: string, data: Buffer, contentType: string, originalId: mongodb.ObjectID) { + return new Promise((resolve, reject) => { + const writeStream = bucket.openUploadStream(name, { + contentType, + metadata: { + originalId + } + }); + + writeStream.once('finish', resolve); + writeStream.on('error', reject); + writeStream.end(data); + }); +} + async function deleteOldFile(user: IRemoteUser) { const oldFile = await DriveFile.findOne({ _id: { diff --git a/src/services/drive/generate-video-thumbnail.ts b/src/services/drive/generate-video-thumbnail.ts index 14b3b98f97..5d7efff27b 100644 --- a/src/services/drive/generate-video-thumbnail.ts +++ b/src/services/drive/generate-video-thumbnail.ts @@ -1,9 +1,9 @@ import * as fs from 'fs'; import * as tmp from 'tmp'; -import * as sharp from 'sharp'; +import { IImage, ConvertToJpeg } from './image-processor'; const ThumbnailGenerator = require('video-thumbnail-generator').default; -export async function GenerateVideoThumbnail(path: string): Promise { +export async function GenerateVideoThumbnail(path: string): Promise { const [outDir, cleanup] = await new Promise<[string, any]>((res, rej) => { tmp.dir((e, path, cleanup) => { if (e) return rej(e); @@ -23,16 +23,7 @@ export async function GenerateVideoThumbnail(path: string): Promise { const outPath = `${outDir}/output.png`; - const thumbnail = await sharp(outPath) - .resize(498, 280, { - fit: 'inside', - withoutEnlargement: true - }) - .jpeg({ - quality: 85, - progressive: true - }) - .toBuffer(); + const thumbnail = await ConvertToJpeg(outPath, 498, 280); // cleanup fs.unlinkSync(outPath); diff --git a/src/services/drive/image-processor.ts b/src/services/drive/image-processor.ts new file mode 100644 index 0000000000..3c538390b0 --- /dev/null +++ b/src/services/drive/image-processor.ts @@ -0,0 +1,75 @@ +import * as sharp from 'sharp'; + +export type IImage = { + data: Buffer; + ext: string; + type: string; +}; + +/** + * Convert to JPEG + * with resize, remove metadata, resolve orientation, stop animation + */ +export async function ConvertToJpeg(path: string, width: number, height: number): Promise { + const data = await sharp(path) + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true + }) + .rotate() + .jpeg({ + quality: 85, + progressive: true + }) + .toBuffer(); + + return { + data, + ext: 'jpg', + type: 'image/jpeg' + }; +} + +/** + * Convert to WebP + * with resize, remove metadata, resolve orientation, stop animation + */ +export async function ConvertToWebp(path: string, width: number, height: number): Promise { + const data = await sharp(path) + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true + }) + .rotate() + .webp({ + quality: 85 + }) + .toBuffer(); + + return { + data, + ext: 'webp', + type: 'image/webp' + }; +} + +/** + * Convert to PNG + * with resize, remove metadata, resolve orientation, stop animation + */ +export async function ConvertToPng(path: string, width: number, height: number): Promise { + const data = await sharp(path) + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true + }) + .rotate() + .png() + .toBuffer(); + + return { + data, + ext: 'png', + type: 'image/png' + }; +}