mirror of
https://activitypub.software/TransFem-org/Sharkey
synced 2024-12-22 16:30:13 +00:00
アニメーションを自動再生しないオプション (#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
|
@ -121,6 +121,7 @@ common:
|
||||||
use-avatar-reversi-stones: "リバーシの石にアバターを使う"
|
use-avatar-reversi-stones: "リバーシの石にアバターを使う"
|
||||||
verified-user: "公式アカウント"
|
verified-user: "公式アカウント"
|
||||||
disable-animated-mfm: "投稿内の動きのあるテキストを無効にする"
|
disable-animated-mfm: "投稿内の動きのあるテキストを無効にする"
|
||||||
|
do-not-autoplay-animation: "アニメーションを自動再生しない"
|
||||||
suggest-recent-hashtags: "最近のハッシュタグを投稿フォームに表示する"
|
suggest-recent-hashtags: "最近のハッシュタグを投稿フォームに表示する"
|
||||||
always-show-nsfw: "常に閲覧注意のメディアを表示する"
|
always-show-nsfw: "常に閲覧注意のメディアを表示する"
|
||||||
always-mark-nsfw: "常にメディアを閲覧注意として投稿"
|
always-mark-nsfw: "常にメディアを閲覧注意として投稿"
|
||||||
|
|
|
@ -117,6 +117,7 @@
|
||||||
<ui-switch v-model="showReplyTarget">{{ $t('show-reply-target') }}</ui-switch>
|
<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="showMaps">{{ $t('show-maps') }}</ui-switch>
|
||||||
<ui-switch v-model="disableAnimatedMfm">{{ $t('@.disable-animated-mfm') }}</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>
|
<ui-switch v-model="remainDeletedNote">{{ $t('remain-deleted-note') }}</ui-switch>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
|
@ -516,6 +517,11 @@ export default Vue.extend({
|
||||||
set(value) { this.$store.dispatch('settings/set', { key: 'disableAnimatedMfm', value }); }
|
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: {
|
remainDeletedNote: {
|
||||||
get() { return this.$store.state.settings.remainDeletedNote; },
|
get() { return this.$store.state.settings.remainDeletedNote; },
|
||||||
set(value) { this.$store.dispatch('settings/set', { key: 'remainDeletedNote', value }); }
|
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="useOsDefaultEmojis">{{ $t('@.use-os-default-emojis') }}</ui-switch>
|
||||||
<ui-switch v-model="iLikeSushi">{{ $t('@.i-like-sushi') }}</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="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="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>
|
<ui-switch v-model="alwaysShowNsfw">{{ $t('@.always-show-nsfw') }} ({{ $t('@.this-setting-is-this-device-only') }})</ui-switch>
|
||||||
</section>
|
</section>
|
||||||
|
@ -313,6 +314,11 @@ export default Vue.extend({
|
||||||
set(value) { this.$store.dispatch('settings/set', { key: 'disableAnimatedMfm', value }); }
|
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: {
|
showReplyTarget: {
|
||||||
get() { return this.$store.state.settings.showReplyTarget; },
|
get() { return this.$store.state.settings.showReplyTarget; },
|
||||||
set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); }
|
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 mongo from 'mongodb';
|
||||||
import * as deepcopy from 'deepcopy';
|
import * as deepcopy from 'deepcopy';
|
||||||
import { pack as packFolder } from './drive-folder';
|
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 monkDb, { nativeDbConn, dbLogger } from '../db/mongodb';
|
||||||
import isObjectId from '../misc/is-objectid';
|
import isObjectId from '../misc/is-objectid';
|
||||||
import getDriveFileUrl, { getOriginalUrl } from '../misc/get-drive-file-url';
|
import getDriveFileUrl, { getOriginalUrl } from '../misc/get-drive-file-url';
|
||||||
|
import wrapUrl from '../misc/wrap-url';
|
||||||
|
|
||||||
const DriveFile = monkDb.get<IDriveFile>('driveFiles.files');
|
const DriveFile = monkDb.get<IDriveFile>('driveFiles.files');
|
||||||
DriveFile.createIndex('md5');
|
DriveFile.createIndex('md5');
|
||||||
|
@ -133,6 +134,7 @@ export const packMany = (
|
||||||
detail?: boolean
|
detail?: boolean
|
||||||
self?: boolean,
|
self?: boolean,
|
||||||
withUser?: boolean,
|
withUser?: boolean,
|
||||||
|
me?: string | mongo.ObjectID | IUser,
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
return Promise.all(files.map(f => pack(f, options)));
|
return Promise.all(files.map(f => pack(f, options)));
|
||||||
|
@ -147,6 +149,7 @@ export const pack = (
|
||||||
detail?: boolean,
|
detail?: boolean,
|
||||||
self?: boolean,
|
self?: boolean,
|
||||||
withUser?: boolean,
|
withUser?: boolean,
|
||||||
|
me?: string | mongo.ObjectID | IUser,
|
||||||
}
|
}
|
||||||
) => new Promise<any>(async (resolve, reject) => {
|
) => new Promise<any>(async (resolve, reject) => {
|
||||||
const opts = Object.assign({
|
const opts = Object.assign({
|
||||||
|
@ -189,6 +192,11 @@ export const pack = (
|
||||||
|
|
||||||
_target.url = getDriveFileUrl(_file);
|
_target.url = getDriveFileUrl(_file);
|
||||||
_target.thumbnailUrl = getDriveFileUrl(_file, true);
|
_target.thumbnailUrl = getDriveFileUrl(_file, true);
|
||||||
|
|
||||||
|
if (_target.thumbnailUrl != null) {
|
||||||
|
_target.thumbnailUrl = wrapUrl(_target.thumbnailUrl, options.me);
|
||||||
|
}
|
||||||
|
|
||||||
_target.isRemote = _file.metadata.isRemote;
|
_target.isRemote = _file.metadata.isRemote;
|
||||||
|
|
||||||
if (_target.properties == null) _target.properties = {};
|
if (_target.properties == null) _target.properties = {};
|
||||||
|
|
|
@ -11,6 +11,7 @@ import Reaction from './note-reaction';
|
||||||
import { packMany as packFileMany, IDriveFile } from './drive-file';
|
import { packMany as packFileMany, IDriveFile } from './drive-file';
|
||||||
import Following from './following';
|
import Following from './following';
|
||||||
import Emoji from './emoji';
|
import Emoji from './emoji';
|
||||||
|
import wrapUrl from '../misc/wrap-url';
|
||||||
|
|
||||||
const Note = db.get<INote>('notes');
|
const Note = db.get<INote>('notes');
|
||||||
Note.createIndex('uri', { sparse: true, unique: true });
|
Note.createIndex('uri', { sparse: true, unique: true });
|
||||||
|
@ -247,11 +248,14 @@ export const pack = async (
|
||||||
fields: { _id: false }
|
fields: { _id: false }
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
_note.emojis = Emoji.find({
|
_note.emojis = (await Emoji.find({
|
||||||
name: { $in: _note.emojis },
|
name: { $in: _note.emojis },
|
||||||
host: host
|
host: host
|
||||||
}, {
|
}, {
|
||||||
fields: { _id: false }
|
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;
|
if (_note.geo) delete _note.geo.type;
|
||||||
|
|
||||||
// Populate user
|
// Populate user
|
||||||
_note.user = packUser(_note.userId, meId);
|
_note.user = packUser(_note.userId, me);
|
||||||
|
|
||||||
// Populate app
|
// Populate app
|
||||||
if (_note.appId) {
|
if (_note.appId) {
|
||||||
|
@ -282,7 +286,7 @@ export const pack = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate files
|
// Populate files
|
||||||
_note.files = packFileMany(_note.fileIds || []);
|
_note.files = packFileMany(_note.fileIds || [], { me });
|
||||||
|
|
||||||
// Some counts
|
// Some counts
|
||||||
_note.renoteCount = _note.renoteCount || 0;
|
_note.renoteCount = _note.renoteCount || 0;
|
||||||
|
|
|
@ -12,6 +12,7 @@ import config from '../config';
|
||||||
import FollowRequest from './follow-request';
|
import FollowRequest from './follow-request';
|
||||||
import fetchMeta from '../misc/fetch-meta';
|
import fetchMeta from '../misc/fetch-meta';
|
||||||
import Emoji from './emoji';
|
import Emoji from './emoji';
|
||||||
|
import wrapUrl from '../misc/wrap-url';
|
||||||
|
|
||||||
const User = db.get<IUser>('users');
|
const User = db.get<IUser>('users');
|
||||||
|
|
||||||
|
@ -344,6 +345,8 @@ export const pack = (
|
||||||
|
|
||||||
if (_user.avatarUrl == null) {
|
if (_user.avatarUrl == null) {
|
||||||
_user.avatarUrl = `${config.drive_url}/default-avatar.jpg`;
|
_user.avatarUrl = `${config.drive_url}/default-avatar.jpg`;
|
||||||
|
} else {
|
||||||
|
_user.avatarUrl = wrapUrl(_user.avatarUrl, me);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!meId || !meId.equals(_user.id) || !opts.detail) {
|
if (!meId || !meId.equals(_user.id) || !opts.detail) {
|
||||||
|
@ -368,7 +371,7 @@ export const pack = (
|
||||||
if (opts.detail) {
|
if (opts.detail) {
|
||||||
if (_user.pinnedNoteIds) {
|
if (_user.pinnedNoteIds) {
|
||||||
// Populate pinned notes
|
// Populate pinned notes
|
||||||
_user.pinnedNotes = packNoteMany(_user.pinnedNoteIds, meId, {
|
_user.pinnedNotes = packNoteMany(_user.pinnedNoteIds, me, {
|
||||||
detail: true
|
detail: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -397,11 +400,14 @@ export const pack = (
|
||||||
|
|
||||||
// カスタム絵文字添付
|
// カスタム絵文字添付
|
||||||
if (_user.emojis) {
|
if (_user.emojis) {
|
||||||
_user.emojis = Emoji.find({
|
_user.emojis = (await Emoji.find({
|
||||||
name: { $in: _user.emojis },
|
name: { $in: _user.emojis },
|
||||||
host: _user.host
|
host: _user.host
|
||||||
}, {
|
}, {
|
||||||
fields: { _id: false }
|
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
|
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('/api', apiServer));
|
||||||
app.use(mount('/files', require('./file')));
|
app.use(mount('/files', require('./file')));
|
||||||
|
app.use(mount('/proxy', require('./proxy')));
|
||||||
|
|
||||||
// Init router
|
// Init router
|
||||||
const router = new 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 fetchMeta from '../../misc/fetch-meta';
|
||||||
import { GenerateVideoThumbnail } from './generate-video-thumbnail';
|
import { GenerateVideoThumbnail } from './generate-video-thumbnail';
|
||||||
import { driveLogger } from './logger';
|
import { driveLogger } from './logger';
|
||||||
|
import { IImage, ConvertToJpeg, ConvertToWebp, ConvertToPng } from './image-processor';
|
||||||
|
|
||||||
const logger = driveLogger.createSubLogger('register', 'yellow');
|
const logger = driveLogger.createSubLogger('register', 'yellow');
|
||||||
|
|
||||||
|
@ -36,99 +37,11 @@ const logger = driveLogger.createSubLogger('register', 'yellow');
|
||||||
* @param metadata
|
* @param metadata
|
||||||
*/
|
*/
|
||||||
async function save(path: string, name: string, type: string, hash: string, size: number, metadata: IMetadata): Promise<IDriveFile> {
|
async function save(path: string, name: string, type: string, hash: string, size: number, metadata: IMetadata): Promise<IDriveFile> {
|
||||||
// #region webpublic
|
// thunbnail, webpublic を必要なら生成
|
||||||
let webpublic: Buffer;
|
const alts = await generateAlts(path, type, !metadata.uri);
|
||||||
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
|
|
||||||
|
|
||||||
if (config.drive && config.drive.storage == 'minio') {
|
if (config.drive && config.drive.storage == 'minio') {
|
||||||
|
//#region ObjectStorage params
|
||||||
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) || ['']);
|
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) || ['']);
|
||||||
|
|
||||||
if (ext === '') {
|
if (ext === '') {
|
||||||
|
@ -137,41 +50,57 @@ async function save(path: string, name: string, type: string, hash: string, size
|
||||||
if (type === 'image/webp') ext = '.webp';
|
if (type === 'image/webp') ext = '.webp';
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `${config.drive.prefix}/${uuid.v4()}${ext}`;
|
const baseUrl = config.drive.baseUrl
|
||||||
const webpublicKey = `${config.drive.prefix}/${uuid.v4()}.${webpublicExt}`;
|
|| `${ config.drive.config.useSSL ? 'https' : 'http' }://${ config.drive.config.endPoint }${ config.drive.config.port ? `:${config.drive.config.port}` : '' }/${ config.drive.bucket }`;
|
||||||
const thumbnailKey = `${config.drive.prefix}/${uuid.v4()}.${thumbnailExt}`;
|
|
||||||
|
|
||||||
|
// 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}`);
|
logger.info(`uploading original: ${key}`);
|
||||||
const uploads = [
|
const uploads = [
|
||||||
upload(key, fs.createReadStream(path), type)
|
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}`);
|
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}`);
|
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);
|
await Promise.all(uploads);
|
||||||
|
//#endregion
|
||||||
|
|
||||||
const baseUrl = config.drive.baseUrl
|
//#region DB
|
||||||
|| `${ config.drive.config.useSSL ? 'https' : 'http' }://${ config.drive.config.endPoint }${ config.drive.config.port ? `:${config.drive.config.port}` : '' }/${ config.drive.bucket }`;
|
|
||||||
|
|
||||||
Object.assign(metadata, {
|
Object.assign(metadata, {
|
||||||
withoutChunks: true,
|
withoutChunks: true,
|
||||||
storage: 'minio',
|
storage: 'minio',
|
||||||
storageProps: {
|
storageProps: {
|
||||||
key: key,
|
key,
|
||||||
webpublicKey: webpublic ? webpublicKey : null,
|
webpublicKey,
|
||||||
thumbnailKey: thumbnail ? thumbnailKey : null,
|
thumbnailKey,
|
||||||
},
|
},
|
||||||
url: `${ baseUrl }/${ key }`,
|
url,
|
||||||
webpublicUrl: webpublic ? `${ baseUrl }/${ webpublicKey }` : null,
|
webpublicUrl,
|
||||||
thumbnailUrl: thumbnail ? `${ baseUrl }/${ thumbnailKey }` : null
|
thumbnailUrl,
|
||||||
} as IMetadata);
|
} as IMetadata);
|
||||||
|
|
||||||
const file = await DriveFile.insert({
|
const file = await DriveFile.insert({
|
||||||
|
@ -182,18 +111,107 @@ async function save(path: string, name: string, type: string, hash: string, size
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
contentType: type
|
contentType: type
|
||||||
});
|
});
|
||||||
|
//#endregion
|
||||||
|
|
||||||
return file;
|
return file;
|
||||||
} else {
|
} else { // use MongoDB GridFS
|
||||||
// #region store original
|
// #region store original
|
||||||
const originalDst = await getDriveFileBucket();
|
const originalDst = await getDriveFileBucket();
|
||||||
|
|
||||||
// web用(Exif削除済み)がある場合はオリジナルにアクセス制限
|
// web用(Exif削除済み)がある場合はオリジナルにアクセス制限
|
||||||
if (webpublic) metadata.accessKey = uuid.v4();
|
if (alts.webpublic) metadata.accessKey = uuid.v4();
|
||||||
|
|
||||||
const originalFile = await new Promise<IDriveFile>((resolve, reject) => {
|
const originalFile = await storeOriginal(originalDst, name, path, type, metadata);
|
||||||
const writeStream = originalDst.openUploadStream(name, {
|
|
||||||
contentType: type,
|
logger.info(`original stored to ${originalFile._id}`);
|
||||||
|
// #endregion store original
|
||||||
|
|
||||||
|
// #region store webpublic
|
||||||
|
if (alts.webpublic) {
|
||||||
|
const webDst = await getDriveFileWebpublicBucket();
|
||||||
|
const webFile = await storeAlts(webDst, name, alts.webpublic.data, alts.webpublic.type, originalFile._id);
|
||||||
|
logger.info(`web stored ${webFile._id}`);
|
||||||
|
}
|
||||||
|
// #endregion store webpublic
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
await minio.putObject(config.drive.bucket, key, stream, null, {
|
||||||
|
'Content-Type': type,
|
||||||
|
'Cache-Control': 'max-age=31536000, immutable'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
metadata
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -201,60 +219,23 @@ async function save(path: string, name: string, type: string, hash: string, size
|
||||||
writeStream.on('error', reject);
|
writeStream.on('error', reject);
|
||||||
fs.createReadStream(path).pipe(writeStream);
|
fs.createReadStream(path).pipe(writeStream);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`original stored to ${originalFile._id}`);
|
|
||||||
// #endregion store original
|
|
||||||
|
|
||||||
// #region store webpublic
|
|
||||||
if (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);
|
|
||||||
});
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return originalFile;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upload(key: string, stream: fs.ReadStream | Buffer, type: string) {
|
/**
|
||||||
const minio = new Minio.Client(config.drive.config);
|
* 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await minio.putObject(config.drive.bucket, key, stream, null, {
|
writeStream.once('finish', resolve);
|
||||||
'Content-Type': type,
|
writeStream.on('error', reject);
|
||||||
'Cache-Control': 'max-age=31536000, immutable'
|
writeStream.end(data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as tmp from 'tmp';
|
import * as tmp from 'tmp';
|
||||||
import * as sharp from 'sharp';
|
import { IImage, ConvertToJpeg } from './image-processor';
|
||||||
const ThumbnailGenerator = require('video-thumbnail-generator').default;
|
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) => {
|
const [outDir, cleanup] = await new Promise<[string, any]>((res, rej) => {
|
||||||
tmp.dir((e, path, cleanup) => {
|
tmp.dir((e, path, cleanup) => {
|
||||||
if (e) return rej(e);
|
if (e) return rej(e);
|
||||||
|
@ -23,16 +23,7 @@ export async function GenerateVideoThumbnail(path: string): Promise<Buffer> {
|
||||||
|
|
||||||
const outPath = `${outDir}/output.png`;
|
const outPath = `${outDir}/output.png`;
|
||||||
|
|
||||||
const thumbnail = await sharp(outPath)
|
const thumbnail = await ConvertToJpeg(outPath, 498, 280);
|
||||||
.resize(498, 280, {
|
|
||||||
fit: 'inside',
|
|
||||||
withoutEnlargement: true
|
|
||||||
})
|
|
||||||
.jpeg({
|
|
||||||
quality: 85,
|
|
||||||
progressive: true
|
|
||||||
})
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
fs.unlinkSync(outPath);
|
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…
Reference in a new issue