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 @@
 				<ui-switch v-model="showReplyTarget">{{ $t('show-reply-target') }}</ui-switch>
 				<ui-switch v-model="showMaps">{{ $t('show-maps') }}</ui-switch>
 				<ui-switch v-model="disableAnimatedMfm">{{ $t('@.disable-animated-mfm') }}</ui-switch>
+				<ui-switch v-model="doNotAutoplayAnimation">{{ $t('@.do-not-autoplay-animation') }}</ui-switch>
 				<ui-switch v-model="remainDeletedNote">{{ $t('remain-deleted-note') }}</ui-switch>
 			</section>
 			<section>
@@ -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 @@
 					<ui-switch v-model="useOsDefaultEmojis">{{ $t('@.use-os-default-emojis') }}</ui-switch>
 					<ui-switch v-model="iLikeSushi">{{ $t('@.i-like-sushi') }}</ui-switch>
 					<ui-switch v-model="disableAnimatedMfm">{{ $t('@.disable-animated-mfm') }}</ui-switch>
+					<ui-switch v-model="doNotAutoplayAnimation">{{ $t('@.do-not-autoplay-animation') }}</ui-switch>
 					<ui-switch v-model="suggestRecentHashtags">{{ $t('@.suggest-recent-hashtags') }}</ui-switch>
 					<ui-switch v-model="alwaysShowNsfw">{{ $t('@.always-show-nsfw') }} ({{ $t('@.this-setting-is-this-device-only') }})</ui-switch>
 				</section>
@@ -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<IDriveFile>('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<any>(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<INote>('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<IUser>('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<IDriveFile> {
-	// #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<IDriveFile>((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<IDriveFile>((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<IDriveFile>((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<IDriveFile>((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<IDriveFile>((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<Buffer> {
+export async function GenerateVideoThumbnail(path: string): Promise<IImage> {
 	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<Buffer> {
 
 	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<IImage> {
+	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<IImage> {
+	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<IImage> {
+	const data = await sharp(path)
+		.resize(width, height, {
+			fit: 'inside',
+			withoutEnlargement: true
+		})
+		.rotate()
+		.png()
+		.toBuffer();
+
+	return {
+		data,
+		ext: 'png',
+		type: 'image/png'
+	};
+}