From fd73fad148a500bd95a575fe6e4b73a25882fb89 Mon Sep 17 00:00:00 2001 From: syuilo Date: Thu, 5 Apr 2018 00:50:57 +0900 Subject: wip --- src/server/api/endpoints/posts/create.ts | 82 +++++--------------------------- 1 file changed, 11 insertions(+), 71 deletions(-) (limited to 'src/server/api/endpoints/posts') diff --git a/src/server/api/endpoints/posts/create.ts b/src/server/api/endpoints/posts/create.ts index 03af7ee763..d241c8c387 100644 --- a/src/server/api/endpoints/posts/create.ts +++ b/src/server/api/endpoints/posts/create.ts @@ -3,16 +3,12 @@ */ import $ from 'cafy'; import deepEqual = require('deep-equal'); -import renderAcct from '../../../../acct/render'; -import config from '../../../../config'; -import html from '../../../../text/html'; -import parse from '../../../../text/parse'; -import Post, { IPost, isValidText, isValidCw } from '../../../../models/post'; +import Post, { IPost, isValidText, isValidCw, pack } from '../../../../models/post'; import { ILocalUser } from '../../../../models/user'; import Channel, { IChannel } from '../../../../models/channel'; import DriveFile from '../../../../models/drive-file'; -import create from '../../../../post/create'; -import distribute from '../../../../post/distribute'; +import create from '../../../../api/post/create'; +import { IApp } from '../../../../models/app'; /** * Create a post @@ -22,7 +18,7 @@ import distribute from '../../../../post/distribute'; * @param {any} app * @return {Promise} */ -module.exports = (params, user: ILocalUser, app) => new Promise(async (res, rej) => { +module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => { // Get 'visibility' parameter const [visibility = 'public', visibilityErr] = $(params.visibility).optional.string().or(['public', 'unlisted', 'private', 'direct']).$; if (visibilityErr) return rej('invalid visibility'); @@ -230,82 +226,26 @@ module.exports = (params, user: ILocalUser, app) => new Promise(async (res, rej) } } - let tokens = null; - if (text) { - // Analyze - tokens = parse(text); - - // Extract hashtags - const hashtags = tokens - .filter(t => t.type == 'hashtag') - .map(t => t.hashtag); - - hashtags.forEach(tag => { - if (tags.indexOf(tag) == -1) { - tags.push(tag); - } - }); - } - - let atMentions = []; - - // If has text content - if (text) { - /* - // Extract a hashtags - const hashtags = tokens - .filter(t => t.type == 'hashtag') - .map(t => t.hashtag) - // Drop dupulicates - .filter((v, i, s) => s.indexOf(v) == i); - - // ハッシュタグをデータベースに登録 - registerHashtags(user, hashtags); - */ - // Extract an '@' mentions - atMentions = tokens - .filter(t => t.type == 'mention') - .map(renderAcct) - // Drop dupulicates - .filter((v, i, s) => s.indexOf(v) == i); - } - // 投稿を作成 - const post = await create({ + const post = await create(user, { createdAt: new Date(), - channelId: channel ? channel._id : undefined, - index: channel ? channel.index + 1 : undefined, - mediaIds: files ? files.map(file => file._id) : [], + media: files, poll: poll, text: text, - textHtml: tokens === null ? null : html(tokens), + reply, + repost, cw: cw, tags: tags, - userId: user._id, - appId: app ? app._id : null, + app: app, viaMobile: viaMobile, visibility, geo - }, reply, repost, atMentions); + }); - const postObj = await distribute(user, post.mentions, post); + const postObj = await pack(post, user); // Reponse res({ createdPost: postObj }); - - // Register to search database - if (post.text && config.elasticsearch.enable) { - const es = require('../../../db/elasticsearch'); - - es.index({ - index: 'misskey', - type: 'post', - id: post._id.toString(), - body: { - text: post.text - } - }); - } }); -- cgit v1.2.3-freya From b6aeacdeb942beb7b5b2f6ac8cf4a89163e59153 Mon Sep 17 00:00:00 2001 From: syuilo Date: Fri, 6 Apr 2018 03:10:25 +0900 Subject: RENAME: api --> services --- src/api/drive/add-file.ts | 314 ------------------- src/api/drive/upload-from-url.ts | 52 ---- src/api/following/create.ts | 72 ----- src/api/following/delete.ts | 64 ---- src/api/post/create.ts | 344 --------------------- src/api/post/watch.ts | 26 -- src/queue/processors/http/report-github-failure.ts | 2 +- src/remote/activitypub/act/create.ts | 4 +- src/remote/activitypub/act/follow.ts | 2 +- src/remote/activitypub/act/unfollow.ts | 2 +- src/remote/activitypub/resolve-person.ts | 2 +- src/server/api/endpoints/following/create.ts | 2 +- src/server/api/endpoints/posts/create.ts | 2 +- src/services/drive/add-file.ts | 314 +++++++++++++++++++ src/services/drive/upload-from-url.ts | 52 ++++ src/services/following/create.ts | 72 +++++ src/services/following/delete.ts | 64 ++++ src/services/post/create.ts | 344 +++++++++++++++++++++ src/services/post/reaction/create.ts | 0 src/services/post/watch.ts | 26 ++ 20 files changed, 880 insertions(+), 880 deletions(-) delete mode 100644 src/api/drive/add-file.ts delete mode 100644 src/api/drive/upload-from-url.ts delete mode 100644 src/api/following/create.ts delete mode 100644 src/api/following/delete.ts delete mode 100644 src/api/post/create.ts delete mode 100644 src/api/post/watch.ts create mode 100644 src/services/drive/add-file.ts create mode 100644 src/services/drive/upload-from-url.ts create mode 100644 src/services/following/create.ts create mode 100644 src/services/following/delete.ts create mode 100644 src/services/post/create.ts create mode 100644 src/services/post/reaction/create.ts create mode 100644 src/services/post/watch.ts (limited to 'src/server/api/endpoints/posts') diff --git a/src/api/drive/add-file.ts b/src/api/drive/add-file.ts deleted file mode 100644 index 64a2f18340..0000000000 --- a/src/api/drive/add-file.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { Buffer } from 'buffer'; -import * as fs from 'fs'; -import * as tmp from 'tmp'; -import * as stream from 'stream'; - -import * as mongodb from 'mongodb'; -import * as crypto from 'crypto'; -import * as _gm from 'gm'; -import * as debug from 'debug'; -import fileType = require('file-type'); -import prominence = require('prominence'); - -import DriveFile, { IMetadata, getGridFSBucket } 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 config from '../../config'; - -const gm = _gm.subClass({ - imageMagick: true -}); - -const log = debug('misskey:drive:add-file'); - -const tmpFile = (): Promise => new Promise((resolve, reject) => { - tmp.file((e, path) => { - if (e) return reject(e); - resolve(path); - }); -}); - -const addToGridFS = (name: string, readable: stream.Readable, type: string, metadata: any): Promise => - getGridFSBucket() - .then(bucket => new Promise((resolve, reject) => { - const writeStream = bucket.openUploadStream(name, { contentType: type, metadata }); - writeStream.once('finish', (doc) => { resolve(doc); }); - writeStream.on('error', reject); - readable.pipe(writeStream); - })); - -const addFile = async ( - user: any, - path: string, - name: string = null, - comment: string = null, - folderId: mongodb.ObjectID = null, - force: boolean = false, - uri: string = null -) => { - log(`registering ${name} (user: ${getAcct(user)}, path: ${path})`); - - // Calculate hash, get content type and get file size - const [hash, [mime, ext], size] = await Promise.all([ - // hash - ((): Promise => new Promise((res, rej) => { - const readable = fs.createReadStream(path); - const hash = crypto.createHash('md5'); - const chunks = []; - readable - .on('error', rej) - .pipe(hash) - .on('error', rej) - .on('data', (chunk) => chunks.push(chunk)) - .on('end', () => { - const buffer = Buffer.concat(chunks); - res(buffer.toString('hex')); - }); - }))(), - // mime - ((): Promise<[string, string | null]> => new Promise((res, rej) => { - const readable = fs.createReadStream(path); - readable - .on('error', rej) - .once('data', (buffer: Buffer) => { - readable.destroy(); - const type = fileType(buffer); - if (type) { - return res([type.mime, type.ext]); - } else { - // 種類が同定できなかったら application/octet-stream にする - return res(['application/octet-stream', null]); - } - }); - }))(), - // size - ((): Promise => new Promise((res, rej) => { - fs.stat(path, (err, stats) => { - if (err) return rej(err); - res(stats.size); - }); - }))() - ]); - - log(`hash: ${hash}, mime: ${mime}, ext: ${ext}, size: ${size}`); - - // detect name - const detectedName: string = name || (ext ? `untitled.${ext}` : 'untitled'); - - if (!force) { - // Check if there is a file with the same hash - const much = await DriveFile.findOne({ - md5: hash, - 'metadata.userId': user._id - }); - - if (much !== null) { - log('file with same hash is found'); - return much; - } else { - log('file with same hash is not found'); - } - } - - const [wh, averageColor, folder] = await Promise.all([ - // Width and height (when image) - (async () => { - // 画像かどうか - if (!/^image\/.*$/.test(mime)) { - return null; - } - - const imageType = mime.split('/')[1]; - - // 画像でもPNGかJPEGかGIFでないならスキップ - if (imageType != 'png' && imageType != 'jpeg' && imageType != 'gif') { - return null; - } - - log('calculate image width and height...'); - - // Calculate width and height - const g = gm(fs.createReadStream(path), name); - const size = await prominence(g).size(); - - log(`image width and height is calculated: ${size.width}, ${size.height}`); - - return [size.width, size.height]; - })(), - // average color (when image) - (async () => { - // 画像かどうか - if (!/^image\/.*$/.test(mime)) { - return null; - } - - const imageType = mime.split('/')[1]; - - // 画像でもPNGかJPEGでないならスキップ - if (imageType != 'png' && imageType != 'jpeg') { - return null; - } - - log('calculate average color...'); - - const buffer = await prominence(gm(fs.createReadStream(path), name) - .setFormat('ppm') - .resize(1, 1)) // 1pxのサイズに縮小して平均色を取得するというハック - .toBuffer(); - - const r = buffer.readUInt8(buffer.length - 3); - const g = buffer.readUInt8(buffer.length - 2); - const b = buffer.readUInt8(buffer.length - 1); - - log(`average color is calculated: ${r}, ${g}, ${b}`); - - return [r, g, b]; - })(), - // folder - (async () => { - if (!folderId) { - return null; - } - const driveFolder = await DriveFolder.findOne({ - _id: folderId, - userId: user._id - }); - if (!driveFolder) { - throw 'folder-not-found'; - } - return driveFolder; - })(), - // usage checker - (async () => { - // Calculate drive usage - const usage = await DriveFile - .aggregate([{ - $match: { 'metadata.userId': user._id } - }, { - $project: { - length: true - } - }, { - $group: { - _id: null, - usage: { $sum: '$length' } - } - }]) - .then((aggregates: any[]) => { - if (aggregates.length > 0) { - return aggregates[0].usage; - } - return 0; - }); - - log(`drive usage is ${usage}`); - - // If usage limit exceeded - if (usage + size > user.driveCapacity) { - throw 'no-free-space'; - } - })() - ]); - - const readable = fs.createReadStream(path); - - const properties = {}; - - if (wh) { - properties['width'] = wh[0]; - properties['height'] = wh[1]; - } - - if (averageColor) { - properties['avgColor'] = averageColor; - } - - const metadata = { - userId: user._id, - folderId: folder !== null ? folder._id : null, - comment: comment, - properties: properties - } as IMetadata; - - if (uri !== null) { - metadata.uri = uri; - } - - return addToGridFS(detectedName, readable, mime, metadata); -}; - -/** - * Add file to drive - * - * @param user User who wish to add file - * @param file File path or readableStream - * @param comment Comment - * @param type File type - * @param folderId Folder ID - * @param force If set to true, forcibly upload the file even if there is a file with the same hash. - * @return Object that represents added file - */ -export default (user: any, file: string | stream.Readable, ...args) => new Promise((resolve, reject) => { - // Get file path - new Promise((res: (v: [string, boolean]) => void, rej) => { - if (typeof file === 'string') { - res([file, false]); - return; - } - if (typeof file === 'object' && typeof file.read === 'function') { - tmpFile() - .then(path => { - const readable: stream.Readable = file; - const writable = fs.createWriteStream(path); - readable - .on('error', rej) - .on('end', () => { - res([path, true]); - }) - .pipe(writable) - .on('error', rej); - }) - .catch(rej); - } - rej(new Error('un-compatible file.')); - }) - .then(([path, shouldCleanup]): Promise => new Promise((res, rej) => { - addFile(user, path, ...args) - .then(file => { - res(file); - if (shouldCleanup) { - fs.unlink(path, (e) => { - if (e) log(e.stack); - }); - } - }) - .catch(rej); - })) - .then(file => { - log(`drive file has been created ${file._id}`); - resolve(file); - - pack(file).then(serializedFile => { - // Publish drive_file_created event - event(user._id, 'drive_file_created', serializedFile); - publishDriveStream(user._id, 'file_created', serializedFile); - - // Register to search database - if (config.elasticsearch.enable) { - const es = require('../db/elasticsearch'); - es.index({ - index: 'misskey', - type: 'drive_file', - id: file._id.toString(), - body: { - name: file.name, - userId: user._id.toString() - } - }); - } - }); - }) - .catch(reject); -}); diff --git a/src/api/drive/upload-from-url.ts b/src/api/drive/upload-from-url.ts deleted file mode 100644 index 676586cd15..0000000000 --- a/src/api/drive/upload-from-url.ts +++ /dev/null @@ -1,52 +0,0 @@ -import * as URL from 'url'; -import { IDriveFile, validateFileName } from '../../models/drive-file'; -import create from './add-file'; -import * as debug from 'debug'; -import * as tmp from 'tmp'; -import * as fs from 'fs'; -import * as request from 'request'; - -const log = debug('misskey:drive:upload-from-url'); - -export default async (url, user, folderId = null, uri = null): Promise => { - log(`REQUESTED: ${url}`); - - let name = URL.parse(url).pathname.split('/').pop(); - if (!validateFileName(name)) { - name = null; - } - - log(`name: ${name}`); - - // Create temp file - const path = await new Promise((res: (string) => void, rej) => { - tmp.file((e, path) => { - if (e) return rej(e); - res(path); - }); - }); - - // write content at URL to temp file - await new Promise((res, rej) => { - const writable = fs.createWriteStream(path); - request(url) - .on('error', rej) - .on('end', () => { - writable.close(); - res(path); - }) - .pipe(writable) - .on('error', rej); - }); - - const driveFile = await create(user, path, name, null, folderId, false, uri); - - log(`created: ${driveFile._id}`); - - // clean-up - fs.unlink(path, (e) => { - if (e) log(e.stack); - }); - - return driveFile; -}; diff --git a/src/api/following/create.ts b/src/api/following/create.ts deleted file mode 100644 index d919f4487f..0000000000 --- a/src/api/following/create.ts +++ /dev/null @@ -1,72 +0,0 @@ -import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../models/user'; -import Following from '../../models/following'; -import FollowingLog from '../../models/following-log'; -import FollowedLog from '../../models/followed-log'; -import event from '../../publishers/stream'; -import notify from '../../publishers/notify'; -import context from '../../remote/activitypub/renderer/context'; -import renderFollow from '../../remote/activitypub/renderer/follow'; -import renderAccept from '../../remote/activitypub/renderer/accept'; -import { deliver } from '../../queue'; - -export default async function(follower: IUser, followee: IUser, activity?) { - const following = await Following.insert({ - createdAt: new Date(), - followerId: follower._id, - followeeId: followee._id - }); - - //#region Increment following count - User.update({ _id: follower._id }, { - $inc: { - followingCount: 1 - } - }); - - FollowingLog.insert({ - createdAt: following.createdAt, - userId: follower._id, - count: follower.followingCount + 1 - }); - //#endregion - - //#region Increment followers count - User.update({ _id: followee._id }, { - $inc: { - followersCount: 1 - } - }); - FollowedLog.insert({ - createdAt: following.createdAt, - userId: followee._id, - count: followee.followersCount + 1 - }); - //#endregion - - // Publish follow event - if (isLocalUser(follower)) { - packUser(followee, follower).then(packed => event(follower._id, 'follow', packed)); - } - - // Publish followed event - if (isLocalUser(followee)) { - packUser(follower, followee).then(packed => event(followee._id, 'followed', packed)), - - // 通知を作成 - notify(followee._id, follower._id, 'follow'); - } - - if (isLocalUser(follower) && isRemoteUser(followee)) { - const content = renderFollow(follower, followee); - content['@context'] = context; - - deliver(follower, content, followee.account.inbox).save(); - } - - if (isRemoteUser(follower) && isLocalUser(followee)) { - const content = renderAccept(activity); - content['@context'] = context; - - deliver(followee, content, follower.account.inbox).save(); - } -} diff --git a/src/api/following/delete.ts b/src/api/following/delete.ts deleted file mode 100644 index 364a4803b9..0000000000 --- a/src/api/following/delete.ts +++ /dev/null @@ -1,64 +0,0 @@ -import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../models/user'; -import Following from '../../models/following'; -import FollowingLog from '../../models/following-log'; -import FollowedLog from '../../models/followed-log'; -import event from '../../publishers/stream'; -import context from '../../remote/activitypub/renderer/context'; -import renderFollow from '../../remote/activitypub/renderer/follow'; -import renderUndo from '../../remote/activitypub/renderer/undo'; -import { deliver } from '../../queue'; - -export default async function(follower: IUser, followee: IUser, activity?) { - const following = await Following.findOne({ - followerId: follower._id, - followeeId: followee._id - }); - - if (following == null) { - console.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); - return; - } - - Following.remove({ - _id: following._id - }); - - //#region Decrement following count - User.update({ _id: follower._id }, { - $inc: { - followingCount: -1 - } - }); - - FollowingLog.insert({ - createdAt: following.createdAt, - userId: follower._id, - count: follower.followingCount - 1 - }); - //#endregion - - //#region Decrement followers count - User.update({ _id: followee._id }, { - $inc: { - followersCount: -1 - } - }); - FollowedLog.insert({ - createdAt: following.createdAt, - userId: followee._id, - count: followee.followersCount - 1 - }); - //#endregion - - // Publish unfollow event - if (isLocalUser(follower)) { - packUser(followee, follower).then(packed => event(follower._id, 'unfollow', packed)); - } - - if (isLocalUser(follower) && isRemoteUser(followee)) { - const content = renderUndo(renderFollow(follower, followee)); - content['@context'] = context; - - deliver(follower, content, followee.account.inbox).save(); - } -} diff --git a/src/api/post/create.ts b/src/api/post/create.ts deleted file mode 100644 index 9723dbe452..0000000000 --- a/src/api/post/create.ts +++ /dev/null @@ -1,344 +0,0 @@ -import Post, { pack, IPost } from '../../models/post'; -import User, { isLocalUser, IUser } from '../../models/user'; -import stream from '../../publishers/stream'; -import Following from '../../models/following'; -import { deliver } from '../../queue'; -import renderNote from '../../remote/activitypub/renderer/note'; -import renderCreate from '../../remote/activitypub/renderer/create'; -import context from '../../remote/activitypub/renderer/context'; -import { IDriveFile } from '../../models/drive-file'; -import notify from '../../publishers/notify'; -import PostWatching from '../../models/post-watching'; -import watch from './watch'; -import Mute from '../../models/mute'; -import pushSw from '../../publishers/push-sw'; -import event from '../../publishers/stream'; -import parse from '../../text/parse'; -import html from '../../text/html'; -import { IApp } from '../../models/app'; - -export default async (user: IUser, content: { - createdAt?: Date; - text?: string; - reply?: IPost; - repost?: IPost; - media?: IDriveFile[]; - geo?: any; - poll?: any; - viaMobile?: boolean; - tags?: string[]; - cw?: string; - visibility?: string; - uri?: string; - app?: IApp; -}, silent = false) => new Promise(async (res, rej) => { - if (content.createdAt == null) content.createdAt = new Date(); - if (content.visibility == null) content.visibility = 'public'; - - const tags = content.tags || []; - - let tokens = null; - - if (content.text) { - // Analyze - tokens = parse(content.text); - - // Extract hashtags - const hashtags = tokens - .filter(t => t.type == 'hashtag') - .map(t => t.hashtag); - - hashtags.forEach(tag => { - if (tags.indexOf(tag) == -1) { - tags.push(tag); - } - }); - } - - const data: any = { - createdAt: content.createdAt, - mediaIds: content.media ? content.media.map(file => file._id) : [], - replyId: content.reply ? content.reply._id : null, - repostId: content.repost ? content.repost._id : null, - text: content.text, - textHtml: tokens === null ? null : html(tokens), - poll: content.poll, - cw: content.cw, - tags, - userId: user._id, - viaMobile: content.viaMobile, - geo: content.geo || null, - appId: content.app ? content.app._id : null, - visibility: content.visibility, - - // 以下非正規化データ - _reply: content.reply ? { userId: content.reply.userId } : null, - _repost: content.repost ? { userId: content.repost.userId } : null, - }; - - if (content.uri != null) data.uri = content.uri; - - // 投稿を作成 - const post = await Post.insert(data); - - res(post); - - User.update({ _id: user._id }, { - // Increment posts count - $inc: { - postsCount: 1 - }, - // Update latest post - $set: { - latestPost: post - } - }); - - // Serialize - const postObj = await pack(post); - - // タイムラインへの投稿 - if (!post.channelId) { - // Publish event to myself's stream - if (isLocalUser(user)) { - stream(post.userId, 'post', postObj); - } - - // Fetch all followers - const followers = await Following.aggregate([{ - $lookup: { - from: 'users', - localField: 'followerId', - foreignField: '_id', - as: 'follower' - } - }, { - $match: { - followeeId: post.userId - } - }], { - _id: false - }); - - if (!silent) { - const note = await renderNote(user, post); - const content = renderCreate(note); - content['@context'] = context; - - Promise.all(followers.map(({ follower }) => { - if (isLocalUser(follower)) { - // Publish event to followers stream - stream(follower._id, 'post', postObj); - } else { - // フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信 - if (isLocalUser(user)) { - deliver(user, content, follower.account.inbox).save(); - } - } - })); - } - } - - // チャンネルへの投稿 - /* TODO - if (post.channelId) { - promises.push( - // Increment channel index(posts count) - Channel.update({ _id: post.channelId }, { - $inc: { - index: 1 - } - }), - - // Publish event to channel - promisedPostObj.then(postObj => { - publishChannelStream(post.channelId, 'post', postObj); - }), - - Promise.all([ - promisedPostObj, - - // Get channel watchers - ChannelWatching.find({ - channelId: post.channelId, - // 削除されたドキュメントは除く - deletedAt: { $exists: false } - }) - ]).then(([postObj, watches]) => { - // チャンネルの視聴者(のタイムライン)に配信 - watches.forEach(w => { - stream(w.userId, 'post', postObj); - }); - }) - ); - }*/ - - const mentions = []; - - async function addMention(mentionee, reason) { - // Reject if already added - if (mentions.some(x => x.equals(mentionee))) return; - - // Add mention - mentions.push(mentionee); - - // Publish event - if (!user._id.equals(mentionee)) { - const mentioneeMutes = await Mute.find({ - muter_id: mentionee, - deleted_at: { $exists: false } - }); - const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId.toString()); - if (mentioneesMutedUserIds.indexOf(user._id.toString()) == -1) { - event(mentionee, reason, postObj); - pushSw(mentionee, reason, postObj); - } - } - } - - // If has in reply to post - if (content.reply) { - // Increment replies count - Post.update({ _id: content.reply._id }, { - $inc: { - repliesCount: 1 - } - }); - - // (自分自身へのリプライでない限りは)通知を作成 - notify(content.reply.userId, user._id, 'reply', { - postId: post._id - }); - - // Fetch watchers - PostWatching.find({ - postId: content.reply._id, - userId: { $ne: user._id }, - // 削除されたドキュメントは除く - deletedAt: { $exists: false } - }, { - fields: { - userId: true - } - }).then(watchers => { - watchers.forEach(watcher => { - notify(watcher.userId, user._id, 'reply', { - postId: post._id - }); - }); - }); - - // この投稿をWatchする - if (isLocalUser(user) && user.account.settings.autoWatch !== false) { - watch(user._id, content.reply); - } - - // Add mention - addMention(content.reply.userId, 'reply'); - } - - // If it is repost - if (content.repost) { - // Notify - const type = content.text ? 'quote' : 'repost'; - notify(content.repost.userId, user._id, type, { - post_id: post._id - }); - - // Fetch watchers - PostWatching.find({ - postId: content.repost._id, - userId: { $ne: user._id }, - // 削除されたドキュメントは除く - deletedAt: { $exists: false } - }, { - fields: { - userId: true - } - }).then(watchers => { - watchers.forEach(watcher => { - notify(watcher.userId, user._id, type, { - postId: post._id - }); - }); - }); - - // この投稿をWatchする - if (isLocalUser(user) && user.account.settings.autoWatch !== false) { - watch(user._id, content.repost); - } - - // If it is quote repost - if (content.text) { - // Add mention - addMention(content.repost.userId, 'quote'); - } else { - // Publish event - if (!user._id.equals(content.repost.userId)) { - event(content.repost.userId, 'repost', postObj); - } - } - - // 今までで同じ投稿をRepostしているか - const existRepost = await Post.findOne({ - userId: user._id, - repostId: content.repost._id, - _id: { - $ne: post._id - } - }); - - if (!existRepost) { - // Update repostee status - Post.update({ _id: content.repost._id }, { - $inc: { - repostCount: 1 - } - }); - } - } - - // If has text content - if (content.text) { - // Extract an '@' mentions - const atMentions = tokens - .filter(t => t.type == 'mention') - .map(m => m.username) - // Drop dupulicates - .filter((v, i, s) => s.indexOf(v) == i); - - // Resolve all mentions - await Promise.all(atMentions.map(async mention => { - // Fetch mentioned user - // SELECT _id - const mentionee = await User - .findOne({ - usernameLower: mention.toLowerCase() - }, { _id: true }); - - // When mentioned user not found - if (mentionee == null) return; - - // 既に言及されたユーザーに対する返信や引用repostの場合も無視 - if (content.reply && content.reply.userId.equals(mentionee._id)) return; - if (content.repost && content.repost.userId.equals(mentionee._id)) return; - - // Add mention - addMention(mentionee._id, 'mention'); - - // Create notification - notify(mentionee._id, user._id, 'mention', { - post_id: post._id - }); - })); - } - - // Append mentions data - if (mentions.length > 0) { - Post.update({ _id: post._id }, { - $set: { - mentions - } - }); - } -}); diff --git a/src/api/post/watch.ts b/src/api/post/watch.ts deleted file mode 100644 index bbd9976f40..0000000000 --- a/src/api/post/watch.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as mongodb from 'mongodb'; -import Watching from '../../models/post-watching'; - -export default async (me: mongodb.ObjectID, post: object) => { - // 自分の投稿はwatchできない - if (me.equals((post as any).userId)) { - return; - } - - // if watching now - const exist = await Watching.findOne({ - postId: (post as any)._id, - userId: me, - deletedAt: { $exists: false } - }); - - if (exist !== null) { - return; - } - - await Watching.insert({ - createdAt: new Date(), - postId: (post as any)._id, - userId: me - }); -}; diff --git a/src/queue/processors/http/report-github-failure.ts b/src/queue/processors/http/report-github-failure.ts index e747d062d3..1e0b51f89f 100644 --- a/src/queue/processors/http/report-github-failure.ts +++ b/src/queue/processors/http/report-github-failure.ts @@ -1,6 +1,6 @@ import * as request from 'request-promise-native'; import User from '../../../models/user'; -import createPost from '../../../api/post/create'; +import createPost from '../../../services/post/create'; export default async ({ data }) => { const asyncBot = User.findOne({ _id: data.userId }); diff --git a/src/remote/activitypub/act/create.ts b/src/remote/activitypub/act/create.ts index fe58f58f87..139c98f3b3 100644 --- a/src/remote/activitypub/act/create.ts +++ b/src/remote/activitypub/act/create.ts @@ -3,8 +3,8 @@ import * as debug from 'debug'; import Resolver from '../resolver'; import Post from '../../../models/post'; -import uploadFromUrl from '../../../api/drive/upload-from-url'; -import createPost from '../../../api/post/create'; +import uploadFromUrl from '../../../services/drive/upload-from-url'; +import createPost from '../../../services/post/create'; import { IRemoteUser, isRemoteUser } from '../../../models/user'; import resolvePerson from '../resolve-person'; diff --git a/src/remote/activitypub/act/follow.ts b/src/remote/activitypub/act/follow.ts index dc173a0acb..4fc423d15c 100644 --- a/src/remote/activitypub/act/follow.ts +++ b/src/remote/activitypub/act/follow.ts @@ -1,7 +1,7 @@ import parseAcct from '../../../acct/parse'; import User from '../../../models/user'; import config from '../../../config'; -import follow from '../../../api/following/create'; +import follow from '../../../services/following/create'; export default async (actor, activity): Promise => { const prefix = config.url + '/@'; diff --git a/src/remote/activitypub/act/unfollow.ts b/src/remote/activitypub/act/unfollow.ts index e3c9e1c1c8..66c15e9a91 100644 --- a/src/remote/activitypub/act/unfollow.ts +++ b/src/remote/activitypub/act/unfollow.ts @@ -1,7 +1,7 @@ import parseAcct from '../../../acct/parse'; import User from '../../../models/user'; import config from '../../../config'; -import unfollow from '../../../api/following/delete'; +import unfollow from '../../../services/following/delete'; export default async (actor, activity): Promise => { const prefix = config.url + '/@'; diff --git a/src/remote/activitypub/resolve-person.ts b/src/remote/activitypub/resolve-person.ts index 2bf7a13540..907f198342 100644 --- a/src/remote/activitypub/resolve-person.ts +++ b/src/remote/activitypub/resolve-person.ts @@ -3,7 +3,7 @@ import { toUnicode } from 'punycode'; import User, { validateUsername, isValidName, isValidDescription } from '../../models/user'; import webFinger from '../webfinger'; import Resolver from './resolver'; -import uploadFromUrl from '../../api/drive/upload-from-url'; +import uploadFromUrl from '../../services/drive/upload-from-url'; export default async (value, verifier?: string) => { const resolver = new Resolver(); diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts index fae686ce54..0ccac8d83d 100644 --- a/src/server/api/endpoints/following/create.ts +++ b/src/server/api/endpoints/following/create.ts @@ -4,7 +4,7 @@ import $ from 'cafy'; import User from '../../../../models/user'; import Following from '../../../../models/following'; -import create from '../../../../api/following/create'; +import create from '../../../../services/following/create'; /** * Follow a user diff --git a/src/server/api/endpoints/posts/create.ts b/src/server/api/endpoints/posts/create.ts index d241c8c387..003a892bc0 100644 --- a/src/server/api/endpoints/posts/create.ts +++ b/src/server/api/endpoints/posts/create.ts @@ -7,7 +7,7 @@ import Post, { IPost, isValidText, isValidCw, pack } from '../../../../models/po import { ILocalUser } from '../../../../models/user'; import Channel, { IChannel } from '../../../../models/channel'; import DriveFile from '../../../../models/drive-file'; -import create from '../../../../api/post/create'; +import create from '../../../../services/post/create'; import { IApp } from '../../../../models/app'; /** diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts new file mode 100644 index 0000000000..64a2f18340 --- /dev/null +++ b/src/services/drive/add-file.ts @@ -0,0 +1,314 @@ +import { Buffer } from 'buffer'; +import * as fs from 'fs'; +import * as tmp from 'tmp'; +import * as stream from 'stream'; + +import * as mongodb from 'mongodb'; +import * as crypto from 'crypto'; +import * as _gm from 'gm'; +import * as debug from 'debug'; +import fileType = require('file-type'); +import prominence = require('prominence'); + +import DriveFile, { IMetadata, getGridFSBucket } 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 config from '../../config'; + +const gm = _gm.subClass({ + imageMagick: true +}); + +const log = debug('misskey:drive:add-file'); + +const tmpFile = (): Promise => new Promise((resolve, reject) => { + tmp.file((e, path) => { + if (e) return reject(e); + resolve(path); + }); +}); + +const addToGridFS = (name: string, readable: stream.Readable, type: string, metadata: any): Promise => + getGridFSBucket() + .then(bucket => new Promise((resolve, reject) => { + const writeStream = bucket.openUploadStream(name, { contentType: type, metadata }); + writeStream.once('finish', (doc) => { resolve(doc); }); + writeStream.on('error', reject); + readable.pipe(writeStream); + })); + +const addFile = async ( + user: any, + path: string, + name: string = null, + comment: string = null, + folderId: mongodb.ObjectID = null, + force: boolean = false, + uri: string = null +) => { + log(`registering ${name} (user: ${getAcct(user)}, path: ${path})`); + + // Calculate hash, get content type and get file size + const [hash, [mime, ext], size] = await Promise.all([ + // hash + ((): Promise => new Promise((res, rej) => { + const readable = fs.createReadStream(path); + const hash = crypto.createHash('md5'); + const chunks = []; + readable + .on('error', rej) + .pipe(hash) + .on('error', rej) + .on('data', (chunk) => chunks.push(chunk)) + .on('end', () => { + const buffer = Buffer.concat(chunks); + res(buffer.toString('hex')); + }); + }))(), + // mime + ((): Promise<[string, string | null]> => new Promise((res, rej) => { + const readable = fs.createReadStream(path); + readable + .on('error', rej) + .once('data', (buffer: Buffer) => { + readable.destroy(); + const type = fileType(buffer); + if (type) { + return res([type.mime, type.ext]); + } else { + // 種類が同定できなかったら application/octet-stream にする + return res(['application/octet-stream', null]); + } + }); + }))(), + // size + ((): Promise => new Promise((res, rej) => { + fs.stat(path, (err, stats) => { + if (err) return rej(err); + res(stats.size); + }); + }))() + ]); + + log(`hash: ${hash}, mime: ${mime}, ext: ${ext}, size: ${size}`); + + // detect name + const detectedName: string = name || (ext ? `untitled.${ext}` : 'untitled'); + + if (!force) { + // Check if there is a file with the same hash + const much = await DriveFile.findOne({ + md5: hash, + 'metadata.userId': user._id + }); + + if (much !== null) { + log('file with same hash is found'); + return much; + } else { + log('file with same hash is not found'); + } + } + + const [wh, averageColor, folder] = await Promise.all([ + // Width and height (when image) + (async () => { + // 画像かどうか + if (!/^image\/.*$/.test(mime)) { + return null; + } + + const imageType = mime.split('/')[1]; + + // 画像でもPNGかJPEGかGIFでないならスキップ + if (imageType != 'png' && imageType != 'jpeg' && imageType != 'gif') { + return null; + } + + log('calculate image width and height...'); + + // Calculate width and height + const g = gm(fs.createReadStream(path), name); + const size = await prominence(g).size(); + + log(`image width and height is calculated: ${size.width}, ${size.height}`); + + return [size.width, size.height]; + })(), + // average color (when image) + (async () => { + // 画像かどうか + if (!/^image\/.*$/.test(mime)) { + return null; + } + + const imageType = mime.split('/')[1]; + + // 画像でもPNGかJPEGでないならスキップ + if (imageType != 'png' && imageType != 'jpeg') { + return null; + } + + log('calculate average color...'); + + const buffer = await prominence(gm(fs.createReadStream(path), name) + .setFormat('ppm') + .resize(1, 1)) // 1pxのサイズに縮小して平均色を取得するというハック + .toBuffer(); + + const r = buffer.readUInt8(buffer.length - 3); + const g = buffer.readUInt8(buffer.length - 2); + const b = buffer.readUInt8(buffer.length - 1); + + log(`average color is calculated: ${r}, ${g}, ${b}`); + + return [r, g, b]; + })(), + // folder + (async () => { + if (!folderId) { + return null; + } + const driveFolder = await DriveFolder.findOne({ + _id: folderId, + userId: user._id + }); + if (!driveFolder) { + throw 'folder-not-found'; + } + return driveFolder; + })(), + // usage checker + (async () => { + // Calculate drive usage + const usage = await DriveFile + .aggregate([{ + $match: { 'metadata.userId': user._id } + }, { + $project: { + length: true + } + }, { + $group: { + _id: null, + usage: { $sum: '$length' } + } + }]) + .then((aggregates: any[]) => { + if (aggregates.length > 0) { + return aggregates[0].usage; + } + return 0; + }); + + log(`drive usage is ${usage}`); + + // If usage limit exceeded + if (usage + size > user.driveCapacity) { + throw 'no-free-space'; + } + })() + ]); + + const readable = fs.createReadStream(path); + + const properties = {}; + + if (wh) { + properties['width'] = wh[0]; + properties['height'] = wh[1]; + } + + if (averageColor) { + properties['avgColor'] = averageColor; + } + + const metadata = { + userId: user._id, + folderId: folder !== null ? folder._id : null, + comment: comment, + properties: properties + } as IMetadata; + + if (uri !== null) { + metadata.uri = uri; + } + + return addToGridFS(detectedName, readable, mime, metadata); +}; + +/** + * Add file to drive + * + * @param user User who wish to add file + * @param file File path or readableStream + * @param comment Comment + * @param type File type + * @param folderId Folder ID + * @param force If set to true, forcibly upload the file even if there is a file with the same hash. + * @return Object that represents added file + */ +export default (user: any, file: string | stream.Readable, ...args) => new Promise((resolve, reject) => { + // Get file path + new Promise((res: (v: [string, boolean]) => void, rej) => { + if (typeof file === 'string') { + res([file, false]); + return; + } + if (typeof file === 'object' && typeof file.read === 'function') { + tmpFile() + .then(path => { + const readable: stream.Readable = file; + const writable = fs.createWriteStream(path); + readable + .on('error', rej) + .on('end', () => { + res([path, true]); + }) + .pipe(writable) + .on('error', rej); + }) + .catch(rej); + } + rej(new Error('un-compatible file.')); + }) + .then(([path, shouldCleanup]): Promise => new Promise((res, rej) => { + addFile(user, path, ...args) + .then(file => { + res(file); + if (shouldCleanup) { + fs.unlink(path, (e) => { + if (e) log(e.stack); + }); + } + }) + .catch(rej); + })) + .then(file => { + log(`drive file has been created ${file._id}`); + resolve(file); + + pack(file).then(serializedFile => { + // Publish drive_file_created event + event(user._id, 'drive_file_created', serializedFile); + publishDriveStream(user._id, 'file_created', serializedFile); + + // Register to search database + if (config.elasticsearch.enable) { + const es = require('../db/elasticsearch'); + es.index({ + index: 'misskey', + type: 'drive_file', + id: file._id.toString(), + body: { + name: file.name, + userId: user._id.toString() + } + }); + } + }); + }) + .catch(reject); +}); diff --git a/src/services/drive/upload-from-url.ts b/src/services/drive/upload-from-url.ts new file mode 100644 index 0000000000..676586cd15 --- /dev/null +++ b/src/services/drive/upload-from-url.ts @@ -0,0 +1,52 @@ +import * as URL from 'url'; +import { IDriveFile, validateFileName } from '../../models/drive-file'; +import create from './add-file'; +import * as debug from 'debug'; +import * as tmp from 'tmp'; +import * as fs from 'fs'; +import * as request from 'request'; + +const log = debug('misskey:drive:upload-from-url'); + +export default async (url, user, folderId = null, uri = null): Promise => { + log(`REQUESTED: ${url}`); + + let name = URL.parse(url).pathname.split('/').pop(); + if (!validateFileName(name)) { + name = null; + } + + log(`name: ${name}`); + + // Create temp file + const path = await new Promise((res: (string) => void, rej) => { + tmp.file((e, path) => { + if (e) return rej(e); + res(path); + }); + }); + + // write content at URL to temp file + await new Promise((res, rej) => { + const writable = fs.createWriteStream(path); + request(url) + .on('error', rej) + .on('end', () => { + writable.close(); + res(path); + }) + .pipe(writable) + .on('error', rej); + }); + + const driveFile = await create(user, path, name, null, folderId, false, uri); + + log(`created: ${driveFile._id}`); + + // clean-up + fs.unlink(path, (e) => { + if (e) log(e.stack); + }); + + return driveFile; +}; diff --git a/src/services/following/create.ts b/src/services/following/create.ts new file mode 100644 index 0000000000..d919f4487f --- /dev/null +++ b/src/services/following/create.ts @@ -0,0 +1,72 @@ +import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../models/user'; +import Following from '../../models/following'; +import FollowingLog from '../../models/following-log'; +import FollowedLog from '../../models/followed-log'; +import event from '../../publishers/stream'; +import notify from '../../publishers/notify'; +import context from '../../remote/activitypub/renderer/context'; +import renderFollow from '../../remote/activitypub/renderer/follow'; +import renderAccept from '../../remote/activitypub/renderer/accept'; +import { deliver } from '../../queue'; + +export default async function(follower: IUser, followee: IUser, activity?) { + const following = await Following.insert({ + createdAt: new Date(), + followerId: follower._id, + followeeId: followee._id + }); + + //#region Increment following count + User.update({ _id: follower._id }, { + $inc: { + followingCount: 1 + } + }); + + FollowingLog.insert({ + createdAt: following.createdAt, + userId: follower._id, + count: follower.followingCount + 1 + }); + //#endregion + + //#region Increment followers count + User.update({ _id: followee._id }, { + $inc: { + followersCount: 1 + } + }); + FollowedLog.insert({ + createdAt: following.createdAt, + userId: followee._id, + count: followee.followersCount + 1 + }); + //#endregion + + // Publish follow event + if (isLocalUser(follower)) { + packUser(followee, follower).then(packed => event(follower._id, 'follow', packed)); + } + + // Publish followed event + if (isLocalUser(followee)) { + packUser(follower, followee).then(packed => event(followee._id, 'followed', packed)), + + // 通知を作成 + notify(followee._id, follower._id, 'follow'); + } + + if (isLocalUser(follower) && isRemoteUser(followee)) { + const content = renderFollow(follower, followee); + content['@context'] = context; + + deliver(follower, content, followee.account.inbox).save(); + } + + if (isRemoteUser(follower) && isLocalUser(followee)) { + const content = renderAccept(activity); + content['@context'] = context; + + deliver(followee, content, follower.account.inbox).save(); + } +} diff --git a/src/services/following/delete.ts b/src/services/following/delete.ts new file mode 100644 index 0000000000..364a4803b9 --- /dev/null +++ b/src/services/following/delete.ts @@ -0,0 +1,64 @@ +import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../models/user'; +import Following from '../../models/following'; +import FollowingLog from '../../models/following-log'; +import FollowedLog from '../../models/followed-log'; +import event from '../../publishers/stream'; +import context from '../../remote/activitypub/renderer/context'; +import renderFollow from '../../remote/activitypub/renderer/follow'; +import renderUndo from '../../remote/activitypub/renderer/undo'; +import { deliver } from '../../queue'; + +export default async function(follower: IUser, followee: IUser, activity?) { + const following = await Following.findOne({ + followerId: follower._id, + followeeId: followee._id + }); + + if (following == null) { + console.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); + return; + } + + Following.remove({ + _id: following._id + }); + + //#region Decrement following count + User.update({ _id: follower._id }, { + $inc: { + followingCount: -1 + } + }); + + FollowingLog.insert({ + createdAt: following.createdAt, + userId: follower._id, + count: follower.followingCount - 1 + }); + //#endregion + + //#region Decrement followers count + User.update({ _id: followee._id }, { + $inc: { + followersCount: -1 + } + }); + FollowedLog.insert({ + createdAt: following.createdAt, + userId: followee._id, + count: followee.followersCount - 1 + }); + //#endregion + + // Publish unfollow event + if (isLocalUser(follower)) { + packUser(followee, follower).then(packed => event(follower._id, 'unfollow', packed)); + } + + if (isLocalUser(follower) && isRemoteUser(followee)) { + const content = renderUndo(renderFollow(follower, followee)); + content['@context'] = context; + + deliver(follower, content, followee.account.inbox).save(); + } +} diff --git a/src/services/post/create.ts b/src/services/post/create.ts new file mode 100644 index 0000000000..9723dbe452 --- /dev/null +++ b/src/services/post/create.ts @@ -0,0 +1,344 @@ +import Post, { pack, IPost } from '../../models/post'; +import User, { isLocalUser, IUser } from '../../models/user'; +import stream from '../../publishers/stream'; +import Following from '../../models/following'; +import { deliver } from '../../queue'; +import renderNote from '../../remote/activitypub/renderer/note'; +import renderCreate from '../../remote/activitypub/renderer/create'; +import context from '../../remote/activitypub/renderer/context'; +import { IDriveFile } from '../../models/drive-file'; +import notify from '../../publishers/notify'; +import PostWatching from '../../models/post-watching'; +import watch from './watch'; +import Mute from '../../models/mute'; +import pushSw from '../../publishers/push-sw'; +import event from '../../publishers/stream'; +import parse from '../../text/parse'; +import html from '../../text/html'; +import { IApp } from '../../models/app'; + +export default async (user: IUser, content: { + createdAt?: Date; + text?: string; + reply?: IPost; + repost?: IPost; + media?: IDriveFile[]; + geo?: any; + poll?: any; + viaMobile?: boolean; + tags?: string[]; + cw?: string; + visibility?: string; + uri?: string; + app?: IApp; +}, silent = false) => new Promise(async (res, rej) => { + if (content.createdAt == null) content.createdAt = new Date(); + if (content.visibility == null) content.visibility = 'public'; + + const tags = content.tags || []; + + let tokens = null; + + if (content.text) { + // Analyze + tokens = parse(content.text); + + // Extract hashtags + const hashtags = tokens + .filter(t => t.type == 'hashtag') + .map(t => t.hashtag); + + hashtags.forEach(tag => { + if (tags.indexOf(tag) == -1) { + tags.push(tag); + } + }); + } + + const data: any = { + createdAt: content.createdAt, + mediaIds: content.media ? content.media.map(file => file._id) : [], + replyId: content.reply ? content.reply._id : null, + repostId: content.repost ? content.repost._id : null, + text: content.text, + textHtml: tokens === null ? null : html(tokens), + poll: content.poll, + cw: content.cw, + tags, + userId: user._id, + viaMobile: content.viaMobile, + geo: content.geo || null, + appId: content.app ? content.app._id : null, + visibility: content.visibility, + + // 以下非正規化データ + _reply: content.reply ? { userId: content.reply.userId } : null, + _repost: content.repost ? { userId: content.repost.userId } : null, + }; + + if (content.uri != null) data.uri = content.uri; + + // 投稿を作成 + const post = await Post.insert(data); + + res(post); + + User.update({ _id: user._id }, { + // Increment posts count + $inc: { + postsCount: 1 + }, + // Update latest post + $set: { + latestPost: post + } + }); + + // Serialize + const postObj = await pack(post); + + // タイムラインへの投稿 + if (!post.channelId) { + // Publish event to myself's stream + if (isLocalUser(user)) { + stream(post.userId, 'post', postObj); + } + + // Fetch all followers + const followers = await Following.aggregate([{ + $lookup: { + from: 'users', + localField: 'followerId', + foreignField: '_id', + as: 'follower' + } + }, { + $match: { + followeeId: post.userId + } + }], { + _id: false + }); + + if (!silent) { + const note = await renderNote(user, post); + const content = renderCreate(note); + content['@context'] = context; + + Promise.all(followers.map(({ follower }) => { + if (isLocalUser(follower)) { + // Publish event to followers stream + stream(follower._id, 'post', postObj); + } else { + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信 + if (isLocalUser(user)) { + deliver(user, content, follower.account.inbox).save(); + } + } + })); + } + } + + // チャンネルへの投稿 + /* TODO + if (post.channelId) { + promises.push( + // Increment channel index(posts count) + Channel.update({ _id: post.channelId }, { + $inc: { + index: 1 + } + }), + + // Publish event to channel + promisedPostObj.then(postObj => { + publishChannelStream(post.channelId, 'post', postObj); + }), + + Promise.all([ + promisedPostObj, + + // Get channel watchers + ChannelWatching.find({ + channelId: post.channelId, + // 削除されたドキュメントは除く + deletedAt: { $exists: false } + }) + ]).then(([postObj, watches]) => { + // チャンネルの視聴者(のタイムライン)に配信 + watches.forEach(w => { + stream(w.userId, 'post', postObj); + }); + }) + ); + }*/ + + const mentions = []; + + async function addMention(mentionee, reason) { + // Reject if already added + if (mentions.some(x => x.equals(mentionee))) return; + + // Add mention + mentions.push(mentionee); + + // Publish event + if (!user._id.equals(mentionee)) { + const mentioneeMutes = await Mute.find({ + muter_id: mentionee, + deleted_at: { $exists: false } + }); + const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId.toString()); + if (mentioneesMutedUserIds.indexOf(user._id.toString()) == -1) { + event(mentionee, reason, postObj); + pushSw(mentionee, reason, postObj); + } + } + } + + // If has in reply to post + if (content.reply) { + // Increment replies count + Post.update({ _id: content.reply._id }, { + $inc: { + repliesCount: 1 + } + }); + + // (自分自身へのリプライでない限りは)通知を作成 + notify(content.reply.userId, user._id, 'reply', { + postId: post._id + }); + + // Fetch watchers + PostWatching.find({ + postId: content.reply._id, + userId: { $ne: user._id }, + // 削除されたドキュメントは除く + deletedAt: { $exists: false } + }, { + fields: { + userId: true + } + }).then(watchers => { + watchers.forEach(watcher => { + notify(watcher.userId, user._id, 'reply', { + postId: post._id + }); + }); + }); + + // この投稿をWatchする + if (isLocalUser(user) && user.account.settings.autoWatch !== false) { + watch(user._id, content.reply); + } + + // Add mention + addMention(content.reply.userId, 'reply'); + } + + // If it is repost + if (content.repost) { + // Notify + const type = content.text ? 'quote' : 'repost'; + notify(content.repost.userId, user._id, type, { + post_id: post._id + }); + + // Fetch watchers + PostWatching.find({ + postId: content.repost._id, + userId: { $ne: user._id }, + // 削除されたドキュメントは除く + deletedAt: { $exists: false } + }, { + fields: { + userId: true + } + }).then(watchers => { + watchers.forEach(watcher => { + notify(watcher.userId, user._id, type, { + postId: post._id + }); + }); + }); + + // この投稿をWatchする + if (isLocalUser(user) && user.account.settings.autoWatch !== false) { + watch(user._id, content.repost); + } + + // If it is quote repost + if (content.text) { + // Add mention + addMention(content.repost.userId, 'quote'); + } else { + // Publish event + if (!user._id.equals(content.repost.userId)) { + event(content.repost.userId, 'repost', postObj); + } + } + + // 今までで同じ投稿をRepostしているか + const existRepost = await Post.findOne({ + userId: user._id, + repostId: content.repost._id, + _id: { + $ne: post._id + } + }); + + if (!existRepost) { + // Update repostee status + Post.update({ _id: content.repost._id }, { + $inc: { + repostCount: 1 + } + }); + } + } + + // If has text content + if (content.text) { + // Extract an '@' mentions + const atMentions = tokens + .filter(t => t.type == 'mention') + .map(m => m.username) + // Drop dupulicates + .filter((v, i, s) => s.indexOf(v) == i); + + // Resolve all mentions + await Promise.all(atMentions.map(async mention => { + // Fetch mentioned user + // SELECT _id + const mentionee = await User + .findOne({ + usernameLower: mention.toLowerCase() + }, { _id: true }); + + // When mentioned user not found + if (mentionee == null) return; + + // 既に言及されたユーザーに対する返信や引用repostの場合も無視 + if (content.reply && content.reply.userId.equals(mentionee._id)) return; + if (content.repost && content.repost.userId.equals(mentionee._id)) return; + + // Add mention + addMention(mentionee._id, 'mention'); + + // Create notification + notify(mentionee._id, user._id, 'mention', { + post_id: post._id + }); + })); + } + + // Append mentions data + if (mentions.length > 0) { + Post.update({ _id: post._id }, { + $set: { + mentions + } + }); + } +}); diff --git a/src/services/post/reaction/create.ts b/src/services/post/reaction/create.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/services/post/watch.ts b/src/services/post/watch.ts new file mode 100644 index 0000000000..bbd9976f40 --- /dev/null +++ b/src/services/post/watch.ts @@ -0,0 +1,26 @@ +import * as mongodb from 'mongodb'; +import Watching from '../../models/post-watching'; + +export default async (me: mongodb.ObjectID, post: object) => { + // 自分の投稿はwatchできない + if (me.equals((post as any).userId)) { + return; + } + + // if watching now + const exist = await Watching.findOne({ + postId: (post as any)._id, + userId: me, + deletedAt: { $exists: false } + }); + + if (exist !== null) { + return; + } + + await Watching.insert({ + createdAt: new Date(), + postId: (post as any)._id, + userId: me + }); +}; -- cgit v1.2.3-freya