アニメーションを自動再生しないオプション (#4131)
* Refactor * settings * Media Proxy * Replace API response
This commit is contained in:
		
							parent
							
								
									00b2d89f1a
								
							
						
					
					
						commit
						f014b7ae0e
					
				
					 14 changed files with 404 additions and 170 deletions
				
			
		| 
						 | 
				
			
			@ -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 }); }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 }); }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										20
									
								
								src/misc/wrap-url.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/misc/wrap-url.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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 = {};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 }));
 | 
			
		||||
}));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										22
									
								
								src/server/proxy/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/server/proxy/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
							
								
								
									
										113
									
								
								src/server/proxy/proxy-media.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								src/server/proxy/proxy-media.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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]);
 | 
			
		||||
			});
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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: {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										75
									
								
								src/services/drive/image-processor.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/services/drive/image-processor.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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'
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue