diff --git a/src/client/app/desktop/views/pages/user/user.header.vue b/src/client/app/desktop/views/pages/user/user.header.vue index 629f6d87c..a00373117 100644 --- a/src/client/app/desktop/views/pages/user/user.header.vue +++ b/src/client/app/desktop/views/pages/user/user.header.vue @@ -2,8 +2,8 @@ <div class="header" :data-is-dark-background="user.bannerUrl != null"> <div class="is-suspended" v-if="user.isSuspended"><p>%fa:exclamation-triangle% %i18n:@is-suspended%</p></div> <div class="is-remote" v-if="user.host != null"><p>%fa:exclamation-triangle% %i18n:@is-remote%<a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></p></div> - <div class="banner-container" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=2048)` : ''"> - <div class="banner" ref="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=2048)` : ''" @click="onBannerClick"></div> + <div class="banner-container" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"> + <div class="banner" ref="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''" @click="onBannerClick"></div> <div class="fade"></div> </div> <div class="container"> diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue index e50086b5f..c602a2638 100644 --- a/src/client/app/mobile/views/pages/user.vue +++ b/src/client/app/mobile/views/pages/user.vue @@ -5,7 +5,7 @@ <div class="is-suspended" v-if="user.isSuspended"><p>%fa:exclamation-triangle% %i18n:@is-suspended%</p></div> <div class="is-remote" v-if="user.host != null"><p>%fa:exclamation-triangle% %i18n:@is-remote%<a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></p></div> <header> - <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=1024)` : ''"></div> + <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> <div class="body"> <div class="top"> <a class="avatar"> diff --git a/src/drive/gen-thumbnail.ts b/src/drive/gen-thumbnail.ts new file mode 100644 index 000000000..455705cd3 --- /dev/null +++ b/src/drive/gen-thumbnail.ts @@ -0,0 +1,25 @@ +import * as stream from 'stream'; +import * as Gm from 'gm'; +import { IDriveFile, getDriveFileBucket } from '../models/drive-file'; + +const gm = Gm.subClass({ + imageMagick: true +}); + +export default async function(file: IDriveFile): Promise<stream.Readable> { + if (!/^image\/.*$/.test(file.contentType)) return null; + + const bucket = await getDriveFileBucket(); + const readable = bucket.openDownloadStream(file._id); + + const g = gm(readable); + + const stream = g + .resize(256, 256) + .compress('jpeg') + .quality(70) + .interlace('line') + .stream(); + + return stream; +} diff --git a/src/models/drive-file-thumbnail.ts b/src/models/drive-file-thumbnail.ts new file mode 100644 index 000000000..46de24379 --- /dev/null +++ b/src/models/drive-file-thumbnail.ts @@ -0,0 +1,61 @@ +import * as mongo from 'mongodb'; +import monkDb, { nativeDbConn } from '../db/mongodb'; + +const DriveFileThumbnail = monkDb.get<IDriveFileThumbnail>('driveFileThumbnails.files'); +DriveFileThumbnail.createIndex('metadata.originalId', { sparse: true, unique: true }); +export default DriveFileThumbnail; + +export const DriveFileThumbnailChunk = monkDb.get('driveFileThumbnails.chunks'); + +export const getDriveFileThumbnailBucket = async (): Promise<mongo.GridFSBucket> => { + const db = await nativeDbConn(); + const bucket = new mongo.GridFSBucket(db, { + bucketName: 'driveFileThumbnails' + }); + return bucket; +}; + +export type IMetadata = { + originalId: mongo.ObjectID; +}; + +export type IDriveFileThumbnail = { + _id: mongo.ObjectID; + uploadDate: Date; + md5: string; + filename: string; + contentType: string; + metadata: IMetadata; +}; + +/** + * DriveFileThumbnailを物理削除します + */ +export async function deleteDriveFileThumbnail(driveFile: string | mongo.ObjectID | IDriveFileThumbnail) { + let d: IDriveFileThumbnail; + + // Populate + if (mongo.ObjectID.prototype.isPrototypeOf(driveFile)) { + d = await DriveFileThumbnail.findOne({ + _id: driveFile + }); + } else if (typeof driveFile === 'string') { + d = await DriveFileThumbnail.findOne({ + _id: new mongo.ObjectID(driveFile) + }); + } else { + d = driveFile as IDriveFileThumbnail; + } + + if (d == null) return; + + // このDriveFileThumbnailのチャンクをすべて削除 + await DriveFileThumbnailChunk.remove({ + files_id: d._id + }); + + // このDriveFileThumbnailを削除 + await DriveFileThumbnail.remove({ + _id: d._id + }); +} diff --git a/src/models/drive-file.ts b/src/models/drive-file.ts index a8878d119..2a7e95363 100644 --- a/src/models/drive-file.ts +++ b/src/models/drive-file.ts @@ -6,6 +6,7 @@ import monkDb, { nativeDbConn } from '../db/mongodb'; import Note, { deleteNote } from './note'; import MessagingMessage, { deleteMessagingMessage } from './messaging-message'; import User from './user'; +import DriveFileThumbnail, { deleteDriveFileThumbnail } from './drive-file-thumbnail'; const DriveFile = monkDb.get<IDriveFile>('driveFiles.files'); DriveFile.createIndex('metadata.uri', { sparse: true, unique: true }); @@ -13,7 +14,7 @@ export default DriveFile; export const DriveFileChunk = monkDb.get('driveFiles.chunks'); -const getGridFSBucket = async (): Promise<mongo.GridFSBucket> => { +export const getDriveFileBucket = async (): Promise<mongo.GridFSBucket> => { const db = await nativeDbConn(); const bucket = new mongo.GridFSBucket(db, { bucketName: 'driveFiles' @@ -21,8 +22,6 @@ const getGridFSBucket = async (): Promise<mongo.GridFSBucket> => { return bucket; }; -export { getGridFSBucket }; - export type IMetadata = { properties: any; userId: mongo.ObjectID; @@ -93,6 +92,11 @@ export async function deleteDriveFile(driveFile: string | mongo.ObjectID | IDriv } } + // このDriveFileのDriveFileThumbnailをすべて削除 + await Promise.all(( + await DriveFileThumbnail.find({ 'metadata.originalId': d._id }) + ).map(x => deleteDriveFileThumbnail(x))); + // このDriveFileのチャンクをすべて削除 await DriveFileChunk.remove({ files_id: d._id diff --git a/src/server/file/index.ts b/src/server/file/index.ts index 29056c63e..973528da3 100644 --- a/src/server/file/index.ts +++ b/src/server/file/index.ts @@ -6,7 +6,6 @@ import * as fs from 'fs'; import * as Koa from 'koa'; import * as cors from '@koa/cors'; import * as Router from 'koa-router'; -import pour from './pour'; import sendDriveFile from './send-drive-file'; // Init app @@ -24,12 +23,14 @@ const router = new Router(); router.get('/default-avatar.jpg', ctx => { const file = fs.createReadStream(`${__dirname}/assets/avatar.jpg`); - pour(file, 'image/jpeg', ctx); + ctx.set('Content-Type', 'image/jpeg'); + ctx.body = file; }); router.get('/app-default.jpg', ctx => { const file = fs.createReadStream(`${__dirname}/assets/dummy.png`); - pour(file, 'image/png', ctx); + ctx.set('Content-Type', 'image/jpeg'); + ctx.body = file; }); router.get('/:id', sendDriveFile); diff --git a/src/server/file/pour.ts b/src/server/file/pour.ts deleted file mode 100644 index 0fd0ad0e6..000000000 --- a/src/server/file/pour.ts +++ /dev/null @@ -1,88 +0,0 @@ -import * as fs from 'fs'; -import * as stream from 'stream'; -import * as Koa from 'koa'; -import * as Gm from 'gm'; - -const gm = Gm.subClass({ - imageMagick: true -}); - -interface ISend { - contentType: string; - stream: stream.Readable; -} - -function thumbnail(data: stream.Readable, type: string, resize: number): ISend { - const readable: stream.Readable = (() => { - // 動画であれば - if (/^video\/.*$/.test(type)) { - // TODO - // 使わないことになったストリームはしっかり取り壊す - data.destroy(); - return fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`); - // 画像であれば - // Note: SVGはapplication/xml - } else if (/^image\/.*$/.test(type) || type == 'application/xml') { - // 0フレーム目を送る - try { - return gm(data).selectFrame(0).stream(); - // だめだったら - } catch (e) { - // 使わないことになったストリームはしっかり取り壊す - data.destroy(); - return fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`); - } - // 動画か画像以外 - } else { - data.destroy(); - return fs.createReadStream(`${__dirname}/assets/not-an-image.png`); - } - })(); - - let g = gm(readable); - - if (resize) { - g = g.resize(resize, resize); - } - - const stream = g - .compress('jpeg') - .quality(80) - .interlace('line') - .stream(); - - return { - contentType: 'image/jpeg', - stream - }; -} - -const commonReadableHandlerGenerator = (ctx: Koa.Context) => (e: Error): void => { - console.error(e); - ctx.status = 500; -}; - -export default function(readable: stream.Readable, type: string, ctx: Koa.Context): void { - readable.on('error', commonReadableHandlerGenerator(ctx)); - - const data = ((): ISend => { - if (ctx.query.thumbnail !== undefined) { - return thumbnail(readable, type, ctx.query.size); - } - return { - contentType: type, - stream: readable - }; - })(); - - if (readable !== data.stream) { - data.stream.on('error', commonReadableHandlerGenerator(ctx)); - } - - if (ctx.query.download !== undefined) { - ctx.set('Content-Disposition', 'attachment'); - } - - ctx.set('Content-Type', data.contentType); - ctx.body = data.stream; -} diff --git a/src/server/file/send-drive-file.ts b/src/server/file/send-drive-file.ts index ef458265a..8719ddf70 100644 --- a/src/server/file/send-drive-file.ts +++ b/src/server/file/send-drive-file.ts @@ -1,8 +1,15 @@ +import * as fs from 'fs'; + import * as Koa from 'koa'; import * as send from 'koa-send'; import * as mongodb from 'mongodb'; -import DriveFile, { getGridFSBucket } from '../../models/drive-file'; -import pour from './pour'; +import DriveFile, { getDriveFileBucket } from '../../models/drive-file'; +import DriveFileThumbnail, { getDriveFileThumbnailBucket } from '../../models/drive-file-thumbnail'; + +const commonReadableHandlerGenerator = (ctx: Koa.Context) => (e: Error): void => { + console.error(e); + ctx.status = 500; +}; export default async function(ctx: Koa.Context) { // Validate id @@ -28,9 +35,33 @@ export default async function(ctx: Koa.Context) { return; } - const bucket = await getGridFSBucket(); + if ('thumbnail' in ctx.query) { + // 動画か画像以外 + if (!/^image\/.*$/.test(file.contentType) && !/^video\/.*$/.test(file.contentType)) { + const readable = fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`); + ctx.set('Content-Type', 'image/png'); + ctx.body = readable; + } else { + const thumb = await DriveFileThumbnail.findOne({ 'metadata.originalId': fileId }); + if (thumb != null) { + ctx.set('Content-Type', 'image/jpeg'); + const bucket = await getDriveFileThumbnailBucket(); + ctx.body = bucket.openDownloadStream(thumb._id); + } else { + ctx.set('Content-Type', file.contentType); + const bucket = await getDriveFileBucket(); + ctx.body = bucket.openDownloadStream(fileId); + } + } + } else { + if ('download' in ctx.query) { + ctx.set('Content-Disposition', 'attachment'); + } - const readable = bucket.openDownloadStream(fileId); - - pour(readable, file.contentType, ctx); + const bucket = await getDriveFileBucket(); + const readable = bucket.openDownloadStream(fileId); + readable.on('error', commonReadableHandlerGenerator(ctx)); + ctx.set('Content-Type', file.contentType); + ctx.body = readable; + } } diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts index 279cdf0bc..e7f3572c7 100644 --- a/src/services/drive/add-file.ts +++ b/src/services/drive/add-file.ts @@ -10,12 +10,14 @@ import * as debug from 'debug'; import fileType = require('file-type'); import prominence = require('prominence'); -import DriveFile, { IMetadata, getGridFSBucket, IDriveFile, DriveFileChunk } from '../../models/drive-file'; +import DriveFile, { IMetadata, getDriveFileBucket, IDriveFile, DriveFileChunk } from '../../models/drive-file'; import DriveFolder from '../../models/drive-folder'; import { pack } from '../../models/drive-file'; import event, { publishDriveStream } from '../../publishers/stream'; import getAcct from '../../acct/render'; import { IUser, isLocalUser } from '../../models/user'; +import DriveFileThumbnail, { getDriveFileThumbnailBucket, DriveFileThumbnailChunk } from '../../models/drive-file-thumbnail'; +import genThumbnail from '../../drive/gen-thumbnail'; const gm = _gm.subClass({ imageMagick: true @@ -30,8 +32,8 @@ const tmpFile = (): Promise<[string, any]> => new Promise((resolve, reject) => { }); }); -const addToGridFS = (name: string, readable: stream.Readable, type: string, metadata: any): Promise<any> => - getGridFSBucket() +const writeChunks = (name: string, readable: stream.Readable, type: string, metadata: any) => + getDriveFileBucket() .then(bucket => new Promise((resolve, reject) => { const writeStream = bucket.openUploadStream(name, { contentType: type, metadata }); writeStream.once('finish', resolve); @@ -39,6 +41,20 @@ const addToGridFS = (name: string, readable: stream.Readable, type: string, meta readable.pipe(writeStream); })); +const writeThumbnailChunks = (name: string, readable: stream.Readable, originalId) => + getDriveFileThumbnailBucket() + .then(bucket => new Promise((resolve, reject) => { + const writeStream = bucket.openUploadStream(name, { + contentType: 'image/jpeg', + metadata: { + originalId + } + }); + writeStream.once('finish', resolve); + writeStream.on('error', reject); + readable.pipe(writeStream); + })); + const addFile = async ( user: IUser, path: string, @@ -232,6 +248,20 @@ const addFile = async ( 'metadata.deletedAt': new Date() } }); + + //#region サムネイルもあれば削除 + const thumbnail = await DriveFileThumbnail.findOne({ + 'metadata.originalId': oldFile._id + }); + + if (thumbnail) { + DriveFileThumbnailChunk.remove({ + files_id: thumbnail._id + }); + + DriveFileThumbnail.remove({ _id: thumbnail._id }); + } + //#endregion } //#endregion } @@ -263,7 +293,18 @@ const addFile = async ( metadata.uri = uri; } - return addToGridFS(detectedName, readable, mime, metadata); + const file = await (writeChunks(detectedName, readable, mime, metadata) as Promise<IDriveFile>); + + try { + const thumb = await genThumbnail(file); + if (thumb) { + await writeThumbnailChunks(detectedName, thumb, file._id); + } + } catch (e) { + // noop + } + + return file; }; /**