summaryrefslogtreecommitdiff
path: root/src/server/api/endpoints/posts
diff options
context:
space:
mode:
authorAkihiko Odaki <nekomanma@pixiv.co.jp>2018-03-29 01:20:40 +0900
committerAkihiko Odaki <nekomanma@pixiv.co.jp>2018-03-29 01:54:41 +0900
commit90f8fe7e538bb7e52d2558152a0390e693f39b11 (patch)
tree0f830887053c8f352b1cd0c13ca715fd14c1f030 /src/server/api/endpoints/posts
parentImplement remote account resolution (diff)
downloadsharkey-90f8fe7e538bb7e52d2558152a0390e693f39b11.tar.gz
sharkey-90f8fe7e538bb7e52d2558152a0390e693f39b11.tar.bz2
sharkey-90f8fe7e538bb7e52d2558152a0390e693f39b11.zip
Introduce processor
Diffstat (limited to 'src/server/api/endpoints/posts')
-rw-r--r--src/server/api/endpoints/posts/categorize.ts52
-rw-r--r--src/server/api/endpoints/posts/context.ts63
-rw-r--r--src/server/api/endpoints/posts/create.ts536
-rw-r--r--src/server/api/endpoints/posts/favorites/create.ts48
-rw-r--r--src/server/api/endpoints/posts/favorites/delete.ts46
-rw-r--r--src/server/api/endpoints/posts/mentions.ts78
-rw-r--r--src/server/api/endpoints/posts/polls/recommendation.ts59
-rw-r--r--src/server/api/endpoints/posts/polls/vote.ts115
-rw-r--r--src/server/api/endpoints/posts/reactions.ts57
-rw-r--r--src/server/api/endpoints/posts/reactions/create.ts122
-rw-r--r--src/server/api/endpoints/posts/reactions/delete.ts60
-rw-r--r--src/server/api/endpoints/posts/replies.ts53
-rw-r--r--src/server/api/endpoints/posts/reposts.ts73
-rw-r--r--src/server/api/endpoints/posts/search.ts364
-rw-r--r--src/server/api/endpoints/posts/show.ts32
-rw-r--r--src/server/api/endpoints/posts/timeline.ts132
-rw-r--r--src/server/api/endpoints/posts/trend.ts79
17 files changed, 1969 insertions, 0 deletions
diff --git a/src/server/api/endpoints/posts/categorize.ts b/src/server/api/endpoints/posts/categorize.ts
new file mode 100644
index 0000000000..0c85c2b4e0
--- /dev/null
+++ b/src/server/api/endpoints/posts/categorize.ts
@@ -0,0 +1,52 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Post from '../../models/post';
+
+/**
+ * Categorize a post
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ if (!user.account.is_pro) {
+ return rej('This endpoint is available only from a Pro account');
+ }
+
+ // Get 'post_id' parameter
+ const [postId, postIdErr] = $(params.post_id).id().$;
+ if (postIdErr) return rej('invalid post_id param');
+
+ // Get categorizee
+ const post = await Post.findOne({
+ _id: postId
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ if (post.is_category_verified) {
+ return rej('This post already has the verified category');
+ }
+
+ // Get 'category' parameter
+ const [category, categoryErr] = $(params.category).string().or([
+ 'music', 'game', 'anime', 'it', 'gadgets', 'photography'
+ ]).$;
+ if (categoryErr) return rej('invalid category param');
+
+ // Set category
+ Post.update({ _id: post._id }, {
+ $set: {
+ category: category,
+ is_category_verified: true
+ }
+ });
+
+ // Send response
+ res();
+});
diff --git a/src/server/api/endpoints/posts/context.ts b/src/server/api/endpoints/posts/context.ts
new file mode 100644
index 0000000000..5ba3758975
--- /dev/null
+++ b/src/server/api/endpoints/posts/context.ts
@@ -0,0 +1,63 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Post, { pack } from '../../models/post';
+
+/**
+ * Show a context of a post
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'post_id' parameter
+ const [postId, postIdErr] = $(params.post_id).id().$;
+ if (postIdErr) return rej('invalid post_id param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ if (offsetErr) return rej('invalid offset param');
+
+ // Lookup post
+ const post = await Post.findOne({
+ _id: postId
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ const context = [];
+ let i = 0;
+
+ async function get(id) {
+ i++;
+ const p = await Post.findOne({ _id: id });
+
+ if (i > offset) {
+ context.push(p);
+ }
+
+ if (context.length == limit) {
+ return;
+ }
+
+ if (p.reply_id) {
+ await get(p.reply_id);
+ }
+ }
+
+ if (post.reply_id) {
+ await get(post.reply_id);
+ }
+
+ // Serialize
+ res(await Promise.all(context.map(async post =>
+ await pack(post, user))));
+});
diff --git a/src/server/api/endpoints/posts/create.ts b/src/server/api/endpoints/posts/create.ts
new file mode 100644
index 0000000000..bc9af843b6
--- /dev/null
+++ b/src/server/api/endpoints/posts/create.ts
@@ -0,0 +1,536 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import deepEqual = require('deep-equal');
+import parse from '../../common/text';
+import { default as Post, IPost, isValidText } from '../../models/post';
+import { default as User, ILocalAccount, IUser } from '../../models/user';
+import { default as Channel, IChannel } from '../../models/channel';
+import Following from '../../models/following';
+import Mute from '../../models/mute';
+import DriveFile from '../../models/drive-file';
+import Watching from '../../models/post-watching';
+import ChannelWatching from '../../models/channel-watching';
+import { pack } from '../../models/post';
+import notify from '../../common/notify';
+import watch from '../../common/watch-post';
+import event, { pushSw, publishChannelStream } from '../../event';
+import getAcct from '../../../common/user/get-acct';
+import parseAcct from '../../../common/user/parse-acct';
+import config from '../../../../conf';
+
+/**
+ * Create a post
+ *
+ * @param {any} params
+ * @param {any} user
+ * @param {any} app
+ * @return {Promise<any>}
+ */
+module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
+ // Get 'text' parameter
+ const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$;
+ if (textErr) return rej('invalid text');
+
+ // Get 'via_mobile' parameter
+ const [viaMobile = false, viaMobileErr] = $(params.via_mobile).optional.boolean().$;
+ if (viaMobileErr) return rej('invalid via_mobile');
+
+ // Get 'tags' parameter
+ const [tags = [], tagsErr] = $(params.tags).optional.array('string').unique().eachQ(t => t.range(1, 32)).$;
+ if (tagsErr) return rej('invalid tags');
+
+ // Get 'geo' parameter
+ const [geo, geoErr] = $(params.geo).optional.nullable.strict.object()
+ .have('latitude', $().number().range(-90, 90))
+ .have('longitude', $().number().range(-180, 180))
+ .have('altitude', $().nullable.number())
+ .have('accuracy', $().nullable.number())
+ .have('altitudeAccuracy', $().nullable.number())
+ .have('heading', $().nullable.number().range(0, 360))
+ .have('speed', $().nullable.number())
+ .$;
+ if (geoErr) return rej('invalid geo');
+
+ // Get 'media_ids' parameter
+ const [mediaIds, mediaIdsErr] = $(params.media_ids).optional.array('id').unique().range(1, 4).$;
+ if (mediaIdsErr) return rej('invalid media_ids');
+
+ let files = [];
+ if (mediaIds !== undefined) {
+ // Fetch files
+ // forEach だと途中でエラーなどがあっても return できないので
+ // 敢えて for を使っています。
+ for (const mediaId of mediaIds) {
+ // Fetch file
+ // SELECT _id
+ const entity = await DriveFile.findOne({
+ _id: mediaId,
+ 'metadata.user_id': user._id
+ });
+
+ if (entity === null) {
+ return rej('file not found');
+ } else {
+ files.push(entity);
+ }
+ }
+ } else {
+ files = null;
+ }
+
+ // Get 'repost_id' parameter
+ const [repostId, repostIdErr] = $(params.repost_id).optional.id().$;
+ if (repostIdErr) return rej('invalid repost_id');
+
+ let repost: IPost = null;
+ let isQuote = false;
+ if (repostId !== undefined) {
+ // Fetch repost to post
+ repost = await Post.findOne({
+ _id: repostId
+ });
+
+ if (repost == null) {
+ return rej('repostee is not found');
+ } else if (repost.repost_id && !repost.text && !repost.media_ids) {
+ return rej('cannot repost to repost');
+ }
+
+ // Fetch recently post
+ const latestPost = await Post.findOne({
+ user_id: user._id
+ }, {
+ sort: {
+ _id: -1
+ }
+ });
+
+ isQuote = text != null || files != null;
+
+ // 直近と同じRepost対象かつ引用じゃなかったらエラー
+ if (latestPost &&
+ latestPost.repost_id &&
+ latestPost.repost_id.equals(repost._id) &&
+ !isQuote) {
+ return rej('cannot repost same post that already reposted in your latest post');
+ }
+
+ // 直近がRepost対象かつ引用じゃなかったらエラー
+ if (latestPost &&
+ latestPost._id.equals(repost._id) &&
+ !isQuote) {
+ return rej('cannot repost your latest post');
+ }
+ }
+
+ // Get 'reply_id' parameter
+ const [replyId, replyIdErr] = $(params.reply_id).optional.id().$;
+ if (replyIdErr) return rej('invalid reply_id');
+
+ let reply: IPost = null;
+ if (replyId !== undefined) {
+ // Fetch reply
+ reply = await Post.findOne({
+ _id: replyId
+ });
+
+ if (reply === null) {
+ return rej('in reply to post is not found');
+ }
+
+ // 返信対象が引用でないRepostだったらエラー
+ if (reply.repost_id && !reply.text && !reply.media_ids) {
+ return rej('cannot reply to repost');
+ }
+ }
+
+ // Get 'channel_id' parameter
+ const [channelId, channelIdErr] = $(params.channel_id).optional.id().$;
+ if (channelIdErr) return rej('invalid channel_id');
+
+ let channel: IChannel = null;
+ if (channelId !== undefined) {
+ // Fetch channel
+ channel = await Channel.findOne({
+ _id: channelId
+ });
+
+ if (channel === null) {
+ return rej('channel not found');
+ }
+
+ // 返信対象の投稿がこのチャンネルじゃなかったらダメ
+ if (reply && !channelId.equals(reply.channel_id)) {
+ return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません');
+ }
+
+ // Repost対象の投稿がこのチャンネルじゃなかったらダメ
+ if (repost && !channelId.equals(repost.channel_id)) {
+ return rej('チャンネル内部からチャンネル外部の投稿をRepostすることはできません');
+ }
+
+ // 引用ではないRepostはダメ
+ if (repost && !isQuote) {
+ return rej('チャンネル内部では引用ではないRepostをすることはできません');
+ }
+ } else {
+ // 返信対象の投稿がチャンネルへの投稿だったらダメ
+ if (reply && reply.channel_id != null) {
+ return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません');
+ }
+
+ // Repost対象の投稿がチャンネルへの投稿だったらダメ
+ if (repost && repost.channel_id != null) {
+ return rej('チャンネル外部からチャンネル内部の投稿をRepostすることはできません');
+ }
+ }
+
+ // Get 'poll' parameter
+ const [poll, pollErr] = $(params.poll).optional.strict.object()
+ .have('choices', $().array('string')
+ .unique()
+ .range(2, 10)
+ .each(c => c.length > 0 && c.length < 50))
+ .$;
+ if (pollErr) return rej('invalid poll');
+
+ if (poll) {
+ (poll as any).choices = (poll as any).choices.map((choice, i) => ({
+ id: i, // IDを付与
+ text: choice.trim(),
+ votes: 0
+ }));
+ }
+
+ // テキストが無いかつ添付ファイルが無いかつRepostも無いかつ投票も無かったらエラー
+ if (text === undefined && files === null && repost === null && poll === undefined) {
+ return rej('text, media_ids, repost_id or poll is required');
+ }
+
+ // 直近の投稿と重複してたらエラー
+ // TODO: 直近の投稿が一日前くらいなら重複とは見なさない
+ if (user.latest_post) {
+ if (deepEqual({
+ text: user.latest_post.text,
+ reply: user.latest_post.reply_id ? user.latest_post.reply_id.toString() : null,
+ repost: user.latest_post.repost_id ? user.latest_post.repost_id.toString() : null,
+ media_ids: (user.latest_post.media_ids || []).map(id => id.toString())
+ }, {
+ text: text,
+ reply: reply ? reply._id.toString() : null,
+ repost: repost ? repost._id.toString() : null,
+ media_ids: (files || []).map(file => file._id.toString())
+ })) {
+ return rej('duplicate');
+ }
+ }
+
+ 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);
+ }
+ });
+ }
+
+ // 投稿を作成
+ const post = await Post.insert({
+ created_at: new Date(),
+ channel_id: channel ? channel._id : undefined,
+ index: channel ? channel.index + 1 : undefined,
+ media_ids: files ? files.map(file => file._id) : undefined,
+ reply_id: reply ? reply._id : undefined,
+ repost_id: repost ? repost._id : undefined,
+ poll: poll,
+ text: text,
+ tags: tags,
+ user_id: user._id,
+ app_id: app ? app._id : null,
+ via_mobile: viaMobile,
+ geo,
+
+ // 以下非正規化データ
+ _reply: reply ? { user_id: reply.user_id } : undefined,
+ _repost: repost ? { user_id: repost.user_id } : undefined,
+ });
+
+ // Serialize
+ const postObj = await pack(post);
+
+ // Reponse
+ res({
+ created_post: postObj
+ });
+
+ //#region Post processes
+
+ User.update({ _id: user._id }, {
+ $set: {
+ latest_post: post
+ }
+ });
+
+ 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.mutee_id.toString());
+ if (mentioneesMutedUserIds.indexOf(user._id.toString()) == -1) {
+ event(mentionee, reason, postObj);
+ pushSw(mentionee, reason, postObj);
+ }
+ }
+ }
+
+ // タイムラインへの投稿
+ if (!channel) {
+ // Publish event to myself's stream
+ event(user._id, 'post', postObj);
+
+ // Fetch all followers
+ const followers = await Following
+ .find({
+ followee_id: user._id,
+ // 削除されたドキュメントは除く
+ deleted_at: { $exists: false }
+ }, {
+ follower_id: true,
+ _id: false
+ });
+
+ // Publish event to followers stream
+ followers.forEach(following =>
+ event(following.follower_id, 'post', postObj));
+ }
+
+ // チャンネルへの投稿
+ if (channel) {
+ // Increment channel index(posts count)
+ Channel.update({ _id: channel._id }, {
+ $inc: {
+ index: 1
+ }
+ });
+
+ // Publish event to channel
+ publishChannelStream(channel._id, 'post', postObj);
+
+ // Get channel watchers
+ const watches = await ChannelWatching.find({
+ channel_id: channel._id,
+ // 削除されたドキュメントは除く
+ deleted_at: { $exists: false }
+ });
+
+ // チャンネルの視聴者(のタイムライン)に配信
+ watches.forEach(w => {
+ event(w.user_id, 'post', postObj);
+ });
+ }
+
+ // Increment my posts count
+ User.update({ _id: user._id }, {
+ $inc: {
+ posts_count: 1
+ }
+ });
+
+ // If has in reply to post
+ if (reply) {
+ // Increment replies count
+ Post.update({ _id: reply._id }, {
+ $inc: {
+ replies_count: 1
+ }
+ });
+
+ // 自分自身へのリプライでない限りは通知を作成
+ notify(reply.user_id, user._id, 'reply', {
+ post_id: post._id
+ });
+
+ // Fetch watchers
+ Watching
+ .find({
+ post_id: reply._id,
+ user_id: { $ne: user._id },
+ // 削除されたドキュメントは除く
+ deleted_at: { $exists: false }
+ }, {
+ fields: {
+ user_id: true
+ }
+ })
+ .then(watchers => {
+ watchers.forEach(watcher => {
+ notify(watcher.user_id, user._id, 'reply', {
+ post_id: post._id
+ });
+ });
+ });
+
+ // この投稿をWatchする
+ if ((user.account as ILocalAccount).settings.auto_watch !== false) {
+ watch(user._id, reply);
+ }
+
+ // Add mention
+ addMention(reply.user_id, 'reply');
+ }
+
+ // If it is repost
+ if (repost) {
+ // Notify
+ const type = text ? 'quote' : 'repost';
+ notify(repost.user_id, user._id, type, {
+ post_id: post._id
+ });
+
+ // Fetch watchers
+ Watching
+ .find({
+ post_id: repost._id,
+ user_id: { $ne: user._id },
+ // 削除されたドキュメントは除く
+ deleted_at: { $exists: false }
+ }, {
+ fields: {
+ user_id: true
+ }
+ })
+ .then(watchers => {
+ watchers.forEach(watcher => {
+ notify(watcher.user_id, user._id, type, {
+ post_id: post._id
+ });
+ });
+ });
+
+ // この投稿をWatchする
+ // TODO: ユーザーが「Repostしたときに自動でWatchする」設定を
+ // オフにしていた場合はしない
+ watch(user._id, repost);
+
+ // If it is quote repost
+ if (text) {
+ // Add mention
+ addMention(repost.user_id, 'quote');
+ } else {
+ // Publish event
+ if (!user._id.equals(repost.user_id)) {
+ event(repost.user_id, 'repost', postObj);
+ }
+ }
+
+ // 今までで同じ投稿をRepostしているか
+ const existRepost = await Post.findOne({
+ user_id: user._id,
+ repost_id: repost._id,
+ _id: {
+ $ne: post._id
+ }
+ });
+
+ if (!existRepost) {
+ // Update repostee status
+ Post.update({ _id: repost._id }, {
+ $inc: {
+ repost_count: 1
+ }
+ });
+ }
+ }
+
+ // 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
+ const atMentions = tokens
+ .filter(t => t.type == 'mention')
+ .map(getAcct)
+ // 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(parseAcct(mention), { _id: true });
+
+ // When mentioned user not found
+ if (mentionee == null) return;
+
+ // 既に言及されたユーザーに対する返信や引用repostの場合も無視
+ if (reply && reply.user_id.equals(mentionee._id)) return;
+ if (repost && repost.user_id.equals(mentionee._id)) return;
+
+ // Add mention
+ addMention(mentionee._id, 'mention');
+
+ // Create notification
+ notify(mentionee._id, user._id, 'mention', {
+ post_id: post._id
+ });
+
+ return;
+ }));
+ }
+
+ // Register to search database
+ if (text && config.elasticsearch.enable) {
+ const es = require('../../../db/elasticsearch');
+
+ es.index({
+ index: 'misskey',
+ type: 'post',
+ id: post._id.toString(),
+ body: {
+ text: post.text
+ }
+ });
+ }
+
+ // Append mentions data
+ if (mentions.length > 0) {
+ Post.update({ _id: post._id }, {
+ $set: {
+ mentions: mentions
+ }
+ });
+ }
+
+ //#endregion
+});
diff --git a/src/server/api/endpoints/posts/favorites/create.ts b/src/server/api/endpoints/posts/favorites/create.ts
new file mode 100644
index 0000000000..f9dee271b5
--- /dev/null
+++ b/src/server/api/endpoints/posts/favorites/create.ts
@@ -0,0 +1,48 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Favorite from '../../../models/favorite';
+import Post from '../../../models/post';
+
+/**
+ * Favorite a post
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'post_id' parameter
+ const [postId, postIdErr] = $(params.post_id).id().$;
+ if (postIdErr) return rej('invalid post_id param');
+
+ // Get favoritee
+ const post = await Post.findOne({
+ _id: postId
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // if already favorited
+ const exist = await Favorite.findOne({
+ post_id: post._id,
+ user_id: user._id
+ });
+
+ if (exist !== null) {
+ return rej('already favorited');
+ }
+
+ // Create favorite
+ await Favorite.insert({
+ created_at: new Date(),
+ post_id: post._id,
+ user_id: user._id
+ });
+
+ // Send response
+ res();
+});
diff --git a/src/server/api/endpoints/posts/favorites/delete.ts b/src/server/api/endpoints/posts/favorites/delete.ts
new file mode 100644
index 0000000000..c4fe7d3234
--- /dev/null
+++ b/src/server/api/endpoints/posts/favorites/delete.ts
@@ -0,0 +1,46 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Favorite from '../../../models/favorite';
+import Post from '../../../models/post';
+
+/**
+ * Unfavorite a post
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'post_id' parameter
+ const [postId, postIdErr] = $(params.post_id).id().$;
+ if (postIdErr) return rej('invalid post_id param');
+
+ // Get favoritee
+ const post = await Post.findOne({
+ _id: postId
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // if already favorited
+ const exist = await Favorite.findOne({
+ post_id: post._id,
+ user_id: user._id
+ });
+
+ if (exist === null) {
+ return rej('already not favorited');
+ }
+
+ // Delete favorite
+ await Favorite.deleteOne({
+ _id: exist._id
+ });
+
+ // Send response
+ res();
+});
diff --git a/src/server/api/endpoints/posts/mentions.ts b/src/server/api/endpoints/posts/mentions.ts
new file mode 100644
index 0000000000..7127db0ad1
--- /dev/null
+++ b/src/server/api/endpoints/posts/mentions.ts
@@ -0,0 +1,78 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Post from '../../models/post';
+import getFriends from '../../common/get-friends';
+import { pack } from '../../models/post';
+
+/**
+ * Get mentions of myself
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'following' parameter
+ const [following = false, followingError] =
+ $(params.following).optional.boolean().$;
+ if (followingError) return rej('invalid following param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'since_id' parameter
+ const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
+ if (sinceIdErr) return rej('invalid since_id param');
+
+ // Get 'until_id' parameter
+ const [untilId, untilIdErr] = $(params.until_id).optional.id().$;
+ if (untilIdErr) return rej('invalid until_id param');
+
+ // Check if both of since_id and until_id is specified
+ if (sinceId && untilId) {
+ return rej('cannot set since_id and until_id');
+ }
+
+ // Construct query
+ const query = {
+ mentions: user._id
+ } as any;
+
+ const sort = {
+ _id: -1
+ };
+
+ if (following) {
+ const followingIds = await getFriends(user._id);
+
+ query.user_id = {
+ $in: followingIds
+ };
+ }
+
+ if (sinceId) {
+ sort._id = 1;
+ query._id = {
+ $gt: sinceId
+ };
+ } else if (untilId) {
+ query._id = {
+ $lt: untilId
+ };
+ }
+
+ // Issue query
+ const mentions = await Post
+ .find(query, {
+ limit: limit,
+ sort: sort
+ });
+
+ // Serialize
+ res(await Promise.all(mentions.map(async mention =>
+ await pack(mention, user)
+ )));
+});
diff --git a/src/server/api/endpoints/posts/polls/recommendation.ts b/src/server/api/endpoints/posts/polls/recommendation.ts
new file mode 100644
index 0000000000..4a3fa3f55e
--- /dev/null
+++ b/src/server/api/endpoints/posts/polls/recommendation.ts
@@ -0,0 +1,59 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Vote from '../../../models/poll-vote';
+import Post, { pack } from '../../../models/post';
+
+/**
+ * Get recommended polls
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ if (offsetErr) return rej('invalid offset param');
+
+ // Get votes
+ const votes = await Vote.find({
+ user_id: user._id
+ }, {
+ fields: {
+ _id: false,
+ post_id: true
+ }
+ });
+
+ const nin = votes && votes.length != 0 ? votes.map(v => v.post_id) : [];
+
+ const posts = await Post
+ .find({
+ _id: {
+ $nin: nin
+ },
+ user_id: {
+ $ne: user._id
+ },
+ poll: {
+ $exists: true,
+ $ne: null
+ }
+ }, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ _id: -1
+ }
+ });
+
+ // Serialize
+ res(await Promise.all(posts.map(async post =>
+ await pack(post, user, { detail: true }))));
+});
diff --git a/src/server/api/endpoints/posts/polls/vote.ts b/src/server/api/endpoints/posts/polls/vote.ts
new file mode 100644
index 0000000000..16ce76a6fa
--- /dev/null
+++ b/src/server/api/endpoints/posts/polls/vote.ts
@@ -0,0 +1,115 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Vote from '../../../models/poll-vote';
+import Post from '../../../models/post';
+import Watching from '../../../models/post-watching';
+import notify from '../../../common/notify';
+import watch from '../../../common/watch-post';
+import { publishPostStream } from '../../../event';
+
+/**
+ * Vote poll of a post
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'post_id' parameter
+ const [postId, postIdErr] = $(params.post_id).id().$;
+ if (postIdErr) return rej('invalid post_id param');
+
+ // Get votee
+ const post = await Post.findOne({
+ _id: postId
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ if (post.poll == null) {
+ return rej('poll not found');
+ }
+
+ // Get 'choice' parameter
+ const [choice, choiceError] =
+ $(params.choice).number()
+ .pipe(c => post.poll.choices.some(x => x.id == c))
+ .$;
+ if (choiceError) return rej('invalid choice param');
+
+ // if already voted
+ const exist = await Vote.findOne({
+ post_id: post._id,
+ user_id: user._id
+ });
+
+ if (exist !== null) {
+ return rej('already voted');
+ }
+
+ // Create vote
+ await Vote.insert({
+ created_at: new Date(),
+ post_id: post._id,
+ user_id: user._id,
+ choice: choice
+ });
+
+ // Send response
+ res();
+
+ const inc = {};
+ inc[`poll.choices.${findWithAttr(post.poll.choices, 'id', choice)}.votes`] = 1;
+
+ // Increment votes count
+ await Post.update({ _id: post._id }, {
+ $inc: inc
+ });
+
+ publishPostStream(post._id, 'poll_voted');
+
+ // Notify
+ notify(post.user_id, user._id, 'poll_vote', {
+ post_id: post._id,
+ choice: choice
+ });
+
+ // Fetch watchers
+ Watching
+ .find({
+ post_id: post._id,
+ user_id: { $ne: user._id },
+ // 削除されたドキュメントは除く
+ deleted_at: { $exists: false }
+ }, {
+ fields: {
+ user_id: true
+ }
+ })
+ .then(watchers => {
+ watchers.forEach(watcher => {
+ notify(watcher.user_id, user._id, 'poll_vote', {
+ post_id: post._id,
+ choice: choice
+ });
+ });
+ });
+
+ // この投稿をWatchする
+ if (user.account.settings.auto_watch !== false) {
+ watch(user._id, post);
+ }
+});
+
+function findWithAttr(array, attr, value) {
+ for (let i = 0; i < array.length; i += 1) {
+ if (array[i][attr] === value) {
+ return i;
+ }
+ }
+ return -1;
+}
diff --git a/src/server/api/endpoints/posts/reactions.ts b/src/server/api/endpoints/posts/reactions.ts
new file mode 100644
index 0000000000..feb140ab41
--- /dev/null
+++ b/src/server/api/endpoints/posts/reactions.ts
@@ -0,0 +1,57 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Post from '../../models/post';
+import Reaction, { pack } from '../../models/post-reaction';
+
+/**
+ * Show reactions of a post
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'post_id' parameter
+ const [postId, postIdErr] = $(params.post_id).id().$;
+ if (postIdErr) return rej('invalid post_id param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ if (offsetErr) return rej('invalid offset param');
+
+ // Get 'sort' parameter
+ const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$;
+ if (sortError) return rej('invalid sort param');
+
+ // Lookup post
+ const post = await Post.findOne({
+ _id: postId
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // Issue query
+ const reactions = await Reaction
+ .find({
+ post_id: post._id,
+ deleted_at: { $exists: false }
+ }, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ _id: sort == 'asc' ? 1 : -1
+ }
+ });
+
+ // Serialize
+ res(await Promise.all(reactions.map(async reaction =>
+ await pack(reaction, user))));
+});
diff --git a/src/server/api/endpoints/posts/reactions/create.ts b/src/server/api/endpoints/posts/reactions/create.ts
new file mode 100644
index 0000000000..f77afed40c
--- /dev/null
+++ b/src/server/api/endpoints/posts/reactions/create.ts
@@ -0,0 +1,122 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Reaction from '../../../models/post-reaction';
+import Post, { pack as packPost } from '../../../models/post';
+import { pack as packUser } from '../../../models/user';
+import Watching from '../../../models/post-watching';
+import notify from '../../../common/notify';
+import watch from '../../../common/watch-post';
+import { publishPostStream, pushSw } from '../../../event';
+
+/**
+ * React to a post
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'post_id' parameter
+ const [postId, postIdErr] = $(params.post_id).id().$;
+ if (postIdErr) return rej('invalid post_id param');
+
+ // Get 'reaction' parameter
+ const [reaction, reactionErr] = $(params.reaction).string().or([
+ 'like',
+ 'love',
+ 'laugh',
+ 'hmm',
+ 'surprise',
+ 'congrats',
+ 'angry',
+ 'confused',
+ 'pudding'
+ ]).$;
+ if (reactionErr) return rej('invalid reaction param');
+
+ // Fetch reactee
+ const post = await Post.findOne({
+ _id: postId
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // Myself
+ if (post.user_id.equals(user._id)) {
+ return rej('cannot react to my post');
+ }
+
+ // if already reacted
+ const exist = await Reaction.findOne({
+ post_id: post._id,
+ user_id: user._id,
+ deleted_at: { $exists: false }
+ });
+
+ if (exist !== null) {
+ return rej('already reacted');
+ }
+
+ // Create reaction
+ await Reaction.insert({
+ created_at: new Date(),
+ post_id: post._id,
+ user_id: user._id,
+ reaction: reaction
+ });
+
+ // Send response
+ res();
+
+ const inc = {};
+ inc[`reaction_counts.${reaction}`] = 1;
+
+ // Increment reactions count
+ await Post.update({ _id: post._id }, {
+ $inc: inc
+ });
+
+ publishPostStream(post._id, 'reacted');
+
+ // Notify
+ notify(post.user_id, user._id, 'reaction', {
+ post_id: post._id,
+ reaction: reaction
+ });
+
+ pushSw(post.user_id, 'reaction', {
+ user: await packUser(user, post.user_id),
+ post: await packPost(post, post.user_id),
+ reaction: reaction
+ });
+
+ // Fetch watchers
+ Watching
+ .find({
+ post_id: post._id,
+ user_id: { $ne: user._id },
+ // 削除されたドキュメントは除く
+ deleted_at: { $exists: false }
+ }, {
+ fields: {
+ user_id: true
+ }
+ })
+ .then(watchers => {
+ watchers.forEach(watcher => {
+ notify(watcher.user_id, user._id, 'reaction', {
+ post_id: post._id,
+ reaction: reaction
+ });
+ });
+ });
+
+ // この投稿をWatchする
+ if (user.account.settings.auto_watch !== false) {
+ watch(user._id, post);
+ }
+});
diff --git a/src/server/api/endpoints/posts/reactions/delete.ts b/src/server/api/endpoints/posts/reactions/delete.ts
new file mode 100644
index 0000000000..922c57ab18
--- /dev/null
+++ b/src/server/api/endpoints/posts/reactions/delete.ts
@@ -0,0 +1,60 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Reaction from '../../../models/post-reaction';
+import Post from '../../../models/post';
+// import event from '../../../event';
+
+/**
+ * Unreact to a post
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'post_id' parameter
+ const [postId, postIdErr] = $(params.post_id).id().$;
+ if (postIdErr) return rej('invalid post_id param');
+
+ // Fetch unreactee
+ const post = await Post.findOne({
+ _id: postId
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // if already unreacted
+ const exist = await Reaction.findOne({
+ post_id: post._id,
+ user_id: user._id,
+ deleted_at: { $exists: false }
+ });
+
+ if (exist === null) {
+ return rej('never reacted');
+ }
+
+ // Delete reaction
+ await Reaction.update({
+ _id: exist._id
+ }, {
+ $set: {
+ deleted_at: new Date()
+ }
+ });
+
+ // Send response
+ res();
+
+ const dec = {};
+ dec[`reaction_counts.${exist.reaction}`] = -1;
+
+ // Decrement reactions count
+ Post.update({ _id: post._id }, {
+ $inc: dec
+ });
+});
diff --git a/src/server/api/endpoints/posts/replies.ts b/src/server/api/endpoints/posts/replies.ts
new file mode 100644
index 0000000000..613c4fa24c
--- /dev/null
+++ b/src/server/api/endpoints/posts/replies.ts
@@ -0,0 +1,53 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Post, { pack } from '../../models/post';
+
+/**
+ * Show a replies of a post
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'post_id' parameter
+ const [postId, postIdErr] = $(params.post_id).id().$;
+ if (postIdErr) return rej('invalid post_id param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ if (offsetErr) return rej('invalid offset param');
+
+ // Get 'sort' parameter
+ const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$;
+ if (sortError) return rej('invalid sort param');
+
+ // Lookup post
+ const post = await Post.findOne({
+ _id: postId
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // Issue query
+ const replies = await Post
+ .find({ reply_id: post._id }, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ _id: sort == 'asc' ? 1 : -1
+ }
+ });
+
+ // Serialize
+ res(await Promise.all(replies.map(async post =>
+ await pack(post, user))));
+});
diff --git a/src/server/api/endpoints/posts/reposts.ts b/src/server/api/endpoints/posts/reposts.ts
new file mode 100644
index 0000000000..89ab0e3d55
--- /dev/null
+++ b/src/server/api/endpoints/posts/reposts.ts
@@ -0,0 +1,73 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Post, { pack } from '../../models/post';
+
+/**
+ * Show a reposts of a post
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'post_id' parameter
+ const [postId, postIdErr] = $(params.post_id).id().$;
+ if (postIdErr) return rej('invalid post_id param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'since_id' parameter
+ const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
+ if (sinceIdErr) return rej('invalid since_id param');
+
+ // Get 'until_id' parameter
+ const [untilId, untilIdErr] = $(params.until_id).optional.id().$;
+ if (untilIdErr) return rej('invalid until_id param');
+
+ // Check if both of since_id and until_id is specified
+ if (sinceId && untilId) {
+ return rej('cannot set since_id and until_id');
+ }
+
+ // Lookup post
+ const post = await Post.findOne({
+ _id: postId
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // Construct query
+ const sort = {
+ _id: -1
+ };
+ const query = {
+ repost_id: post._id
+ } as any;
+ if (sinceId) {
+ sort._id = 1;
+ query._id = {
+ $gt: sinceId
+ };
+ } else if (untilId) {
+ query._id = {
+ $lt: untilId
+ };
+ }
+
+ // Issue query
+ const reposts = await Post
+ .find(query, {
+ limit: limit,
+ sort: sort
+ });
+
+ // Serialize
+ res(await Promise.all(reposts.map(async post =>
+ await pack(post, user))));
+});
diff --git a/src/server/api/endpoints/posts/search.ts b/src/server/api/endpoints/posts/search.ts
new file mode 100644
index 0000000000..a36d1178a5
--- /dev/null
+++ b/src/server/api/endpoints/posts/search.ts
@@ -0,0 +1,364 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+const escapeRegexp = require('escape-regexp');
+import Post from '../../models/post';
+import User from '../../models/user';
+import Mute from '../../models/mute';
+import getFriends from '../../common/get-friends';
+import { pack } from '../../models/post';
+
+/**
+ * Search a post
+ *
+ * @param {any} params
+ * @param {any} me
+ * @return {Promise<any>}
+ */
+module.exports = (params, me) => new Promise(async (res, rej) => {
+ // Get 'text' parameter
+ const [text, textError] = $(params.text).optional.string().$;
+ if (textError) return rej('invalid text param');
+
+ // Get 'include_user_ids' parameter
+ const [includeUserIds = [], includeUserIdsErr] = $(params.include_user_ids).optional.array('id').$;
+ if (includeUserIdsErr) return rej('invalid include_user_ids param');
+
+ // Get 'exclude_user_ids' parameter
+ const [excludeUserIds = [], excludeUserIdsErr] = $(params.exclude_user_ids).optional.array('id').$;
+ if (excludeUserIdsErr) return rej('invalid exclude_user_ids param');
+
+ // Get 'include_user_usernames' parameter
+ const [includeUserUsernames = [], includeUserUsernamesErr] = $(params.include_user_usernames).optional.array('string').$;
+ if (includeUserUsernamesErr) return rej('invalid include_user_usernames param');
+
+ // Get 'exclude_user_usernames' parameter
+ const [excludeUserUsernames = [], excludeUserUsernamesErr] = $(params.exclude_user_usernames).optional.array('string').$;
+ if (excludeUserUsernamesErr) return rej('invalid exclude_user_usernames param');
+
+ // Get 'following' parameter
+ const [following = null, followingErr] = $(params.following).optional.nullable.boolean().$;
+ if (followingErr) return rej('invalid following param');
+
+ // Get 'mute' parameter
+ const [mute = 'mute_all', muteErr] = $(params.mute).optional.string().$;
+ if (muteErr) return rej('invalid mute param');
+
+ // Get 'reply' parameter
+ const [reply = null, replyErr] = $(params.reply).optional.nullable.boolean().$;
+ if (replyErr) return rej('invalid reply param');
+
+ // Get 'repost' parameter
+ const [repost = null, repostErr] = $(params.repost).optional.nullable.boolean().$;
+ if (repostErr) return rej('invalid repost param');
+
+ // Get 'media' parameter
+ const [media = null, mediaErr] = $(params.media).optional.nullable.boolean().$;
+ if (mediaErr) return rej('invalid media param');
+
+ // Get 'poll' parameter
+ const [poll = null, pollErr] = $(params.poll).optional.nullable.boolean().$;
+ if (pollErr) return rej('invalid poll param');
+
+ // Get 'since_date' parameter
+ const [sinceDate, sinceDateErr] = $(params.since_date).optional.number().$;
+ if (sinceDateErr) throw 'invalid since_date param';
+
+ // Get 'until_date' parameter
+ const [untilDate, untilDateErr] = $(params.until_date).optional.number().$;
+ if (untilDateErr) throw 'invalid until_date param';
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ if (offsetErr) return rej('invalid offset param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 30).$;
+ if (limitErr) return rej('invalid limit param');
+
+ let includeUsers = includeUserIds;
+ if (includeUserUsernames != null) {
+ const ids = (await Promise.all(includeUserUsernames.map(async (username) => {
+ const _user = await User.findOne({
+ username_lower: username.toLowerCase()
+ });
+ return _user ? _user._id : null;
+ }))).filter(id => id != null);
+ includeUsers = includeUsers.concat(ids);
+ }
+
+ let excludeUsers = excludeUserIds;
+ if (excludeUserUsernames != null) {
+ const ids = (await Promise.all(excludeUserUsernames.map(async (username) => {
+ const _user = await User.findOne({
+ username_lower: username.toLowerCase()
+ });
+ return _user ? _user._id : null;
+ }))).filter(id => id != null);
+ excludeUsers = excludeUsers.concat(ids);
+ }
+
+ search(res, rej, me, text, includeUsers, excludeUsers, following,
+ mute, reply, repost, media, poll, sinceDate, untilDate, offset, limit);
+});
+
+async function search(
+ res, rej, me, text, includeUserIds, excludeUserIds, following,
+ mute, reply, repost, media, poll, sinceDate, untilDate, offset, max) {
+
+ let q: any = {
+ $and: []
+ };
+
+ const push = x => q.$and.push(x);
+
+ if (text) {
+ // 完全一致検索
+ if (/"""(.+?)"""/.test(text)) {
+ const x = text.match(/"""(.+?)"""/)[1];
+ push({
+ text: x
+ });
+ } else {
+ const tags = text.split(' ').filter(x => x[0] == '#');
+ if (tags) {
+ push({
+ $and: tags.map(x => ({
+ tags: x
+ }))
+ });
+ }
+
+ push({
+ $and: text.split(' ').map(x => ({
+ // キーワードが-で始まる場合そのキーワードを除外する
+ text: x[0] == '-' ? {
+ $not: new RegExp(escapeRegexp(x.substr(1)))
+ } : new RegExp(escapeRegexp(x))
+ }))
+ });
+ }
+ }
+
+ if (includeUserIds && includeUserIds.length != 0) {
+ push({
+ user_id: {
+ $in: includeUserIds
+ }
+ });
+ } else if (excludeUserIds && excludeUserIds.length != 0) {
+ push({
+ user_id: {
+ $nin: excludeUserIds
+ }
+ });
+ }
+
+ if (following != null && me != null) {
+ const ids = await getFriends(me._id, false);
+ push({
+ user_id: following ? {
+ $in: ids
+ } : {
+ $nin: ids.concat(me._id)
+ }
+ });
+ }
+
+ if (me != null) {
+ const mutes = await Mute.find({
+ muter_id: me._id,
+ deleted_at: { $exists: false }
+ });
+ const mutedUserIds = mutes.map(m => m.mutee_id);
+
+ switch (mute) {
+ case 'mute_all':
+ push({
+ user_id: {
+ $nin: mutedUserIds
+ },
+ '_reply.user_id': {
+ $nin: mutedUserIds
+ },
+ '_repost.user_id': {
+ $nin: mutedUserIds
+ }
+ });
+ break;
+ case 'mute_related':
+ push({
+ '_reply.user_id': {
+ $nin: mutedUserIds
+ },
+ '_repost.user_id': {
+ $nin: mutedUserIds
+ }
+ });
+ break;
+ case 'mute_direct':
+ push({
+ user_id: {
+ $nin: mutedUserIds
+ }
+ });
+ break;
+ case 'direct_only':
+ push({
+ user_id: {
+ $in: mutedUserIds
+ }
+ });
+ break;
+ case 'related_only':
+ push({
+ $or: [{
+ '_reply.user_id': {
+ $in: mutedUserIds
+ }
+ }, {
+ '_repost.user_id': {
+ $in: mutedUserIds
+ }
+ }]
+ });
+ break;
+ case 'all_only':
+ push({
+ $or: [{
+ user_id: {
+ $in: mutedUserIds
+ }
+ }, {
+ '_reply.user_id': {
+ $in: mutedUserIds
+ }
+ }, {
+ '_repost.user_id': {
+ $in: mutedUserIds
+ }
+ }]
+ });
+ break;
+ }
+ }
+
+ if (reply != null) {
+ if (reply) {
+ push({
+ reply_id: {
+ $exists: true,
+ $ne: null
+ }
+ });
+ } else {
+ push({
+ $or: [{
+ reply_id: {
+ $exists: false
+ }
+ }, {
+ reply_id: null
+ }]
+ });
+ }
+ }
+
+ if (repost != null) {
+ if (repost) {
+ push({
+ repost_id: {
+ $exists: true,
+ $ne: null
+ }
+ });
+ } else {
+ push({
+ $or: [{
+ repost_id: {
+ $exists: false
+ }
+ }, {
+ repost_id: null
+ }]
+ });
+ }
+ }
+
+ if (media != null) {
+ if (media) {
+ push({
+ media_ids: {
+ $exists: true,
+ $ne: null
+ }
+ });
+ } else {
+ push({
+ $or: [{
+ media_ids: {
+ $exists: false
+ }
+ }, {
+ media_ids: null
+ }]
+ });
+ }
+ }
+
+ if (poll != null) {
+ if (poll) {
+ push({
+ poll: {
+ $exists: true,
+ $ne: null
+ }
+ });
+ } else {
+ push({
+ $or: [{
+ poll: {
+ $exists: false
+ }
+ }, {
+ poll: null
+ }]
+ });
+ }
+ }
+
+ if (sinceDate) {
+ push({
+ created_at: {
+ $gt: new Date(sinceDate)
+ }
+ });
+ }
+
+ if (untilDate) {
+ push({
+ created_at: {
+ $lt: new Date(untilDate)
+ }
+ });
+ }
+
+ if (q.$and.length == 0) {
+ q = {};
+ }
+
+ // Search posts
+ const posts = await Post
+ .find(q, {
+ sort: {
+ _id: -1
+ },
+ limit: max,
+ skip: offset
+ });
+
+ // Serialize
+ res(await Promise.all(posts.map(async post =>
+ await pack(post, me))));
+}
diff --git a/src/server/api/endpoints/posts/show.ts b/src/server/api/endpoints/posts/show.ts
new file mode 100644
index 0000000000..3839490597
--- /dev/null
+++ b/src/server/api/endpoints/posts/show.ts
@@ -0,0 +1,32 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Post, { pack } from '../../models/post';
+
+/**
+ * Show a post
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'post_id' parameter
+ const [postId, postIdErr] = $(params.post_id).id().$;
+ if (postIdErr) return rej('invalid post_id param');
+
+ // Get post
+ const post = await Post.findOne({
+ _id: postId
+ });
+
+ if (post === null) {
+ return rej('post not found');
+ }
+
+ // Serialize
+ res(await pack(post, user, {
+ detail: true
+ }));
+});
diff --git a/src/server/api/endpoints/posts/timeline.ts b/src/server/api/endpoints/posts/timeline.ts
new file mode 100644
index 0000000000..c41cfdb8bd
--- /dev/null
+++ b/src/server/api/endpoints/posts/timeline.ts
@@ -0,0 +1,132 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import rap from '@prezzemolo/rap';
+import Post from '../../models/post';
+import Mute from '../../models/mute';
+import ChannelWatching from '../../models/channel-watching';
+import getFriends from '../../common/get-friends';
+import { pack } from '../../models/post';
+
+/**
+ * Get timeline of myself
+ *
+ * @param {any} params
+ * @param {any} user
+ * @param {any} app
+ * @return {Promise<any>}
+ */
+module.exports = async (params, user, app) => {
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) throw 'invalid limit param';
+
+ // Get 'since_id' parameter
+ const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
+ if (sinceIdErr) throw 'invalid since_id param';
+
+ // Get 'until_id' parameter
+ const [untilId, untilIdErr] = $(params.until_id).optional.id().$;
+ if (untilIdErr) throw 'invalid until_id param';
+
+ // Get 'since_date' parameter
+ const [sinceDate, sinceDateErr] = $(params.since_date).optional.number().$;
+ if (sinceDateErr) throw 'invalid since_date param';
+
+ // Get 'until_date' parameter
+ const [untilDate, untilDateErr] = $(params.until_date).optional.number().$;
+ if (untilDateErr) throw 'invalid until_date param';
+
+ // Check if only one of since_id, until_id, since_date, until_date specified
+ if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) {
+ throw 'only one of since_id, until_id, since_date, until_date can be specified';
+ }
+
+ const { followingIds, watchingChannelIds, mutedUserIds } = await rap({
+ // ID list of the user itself and other users who the user follows
+ followingIds: getFriends(user._id),
+
+ // Watchしているチャンネルを取得
+ watchingChannelIds: ChannelWatching.find({
+ user_id: user._id,
+ // 削除されたドキュメントは除く
+ deleted_at: { $exists: false }
+ }).then(watches => watches.map(w => w.channel_id)),
+
+ // ミュートしているユーザーを取得
+ mutedUserIds: Mute.find({
+ muter_id: user._id,
+ // 削除されたドキュメントは除く
+ deleted_at: { $exists: false }
+ }).then(ms => ms.map(m => m.mutee_id))
+ });
+
+ //#region Construct query
+ const sort = {
+ _id: -1
+ };
+
+ const query = {
+ $or: [{
+ // フォローしている人のタイムラインへの投稿
+ user_id: {
+ $in: followingIds
+ },
+ // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る
+ $or: [{
+ channel_id: {
+ $exists: false
+ }
+ }, {
+ channel_id: null
+ }]
+ }, {
+ // Watchしているチャンネルへの投稿
+ channel_id: {
+ $in: watchingChannelIds
+ }
+ }],
+ // mute
+ user_id: {
+ $nin: mutedUserIds
+ },
+ '_reply.user_id': {
+ $nin: mutedUserIds
+ },
+ '_repost.user_id': {
+ $nin: mutedUserIds
+ },
+ } as any;
+
+ if (sinceId) {
+ sort._id = 1;
+ query._id = {
+ $gt: sinceId
+ };
+ } else if (untilId) {
+ query._id = {
+ $lt: untilId
+ };
+ } else if (sinceDate) {
+ sort._id = 1;
+ query.created_at = {
+ $gt: new Date(sinceDate)
+ };
+ } else if (untilDate) {
+ query.created_at = {
+ $lt: new Date(untilDate)
+ };
+ }
+ //#endregion
+
+ // Issue query
+ const timeline = await Post
+ .find(query, {
+ limit: limit,
+ sort: sort
+ });
+
+ // Serialize
+ return await Promise.all(timeline.map(post => pack(post, user)));
+};
diff --git a/src/server/api/endpoints/posts/trend.ts b/src/server/api/endpoints/posts/trend.ts
new file mode 100644
index 0000000000..caded92bf5
--- /dev/null
+++ b/src/server/api/endpoints/posts/trend.ts
@@ -0,0 +1,79 @@
+/**
+ * Module dependencies
+ */
+const ms = require('ms');
+import $ from 'cafy';
+import Post, { pack } from '../../models/post';
+
+/**
+ * Get trend posts
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
+ if (offsetErr) return rej('invalid offset param');
+
+ // Get 'reply' parameter
+ const [reply, replyErr] = $(params.reply).optional.boolean().$;
+ if (replyErr) return rej('invalid reply param');
+
+ // Get 'repost' parameter
+ const [repost, repostErr] = $(params.repost).optional.boolean().$;
+ if (repostErr) return rej('invalid repost param');
+
+ // Get 'media' parameter
+ const [media, mediaErr] = $(params.media).optional.boolean().$;
+ if (mediaErr) return rej('invalid media param');
+
+ // Get 'poll' parameter
+ const [poll, pollErr] = $(params.poll).optional.boolean().$;
+ if (pollErr) return rej('invalid poll param');
+
+ const query = {
+ created_at: {
+ $gte: new Date(Date.now() - ms('1days'))
+ },
+ repost_count: {
+ $gt: 0
+ }
+ } as any;
+
+ if (reply != undefined) {
+ query.reply_id = reply ? { $exists: true, $ne: null } : null;
+ }
+
+ if (repost != undefined) {
+ query.repost_id = repost ? { $exists: true, $ne: null } : null;
+ }
+
+ if (media != undefined) {
+ query.media_ids = media ? { $exists: true, $ne: null } : null;
+ }
+
+ if (poll != undefined) {
+ query.poll = poll ? { $exists: true, $ne: null } : null;
+ }
+
+ // Issue query
+ const posts = await Post
+ .find(query, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ repost_count: -1,
+ _id: -1
+ }
+ });
+
+ // Serialize
+ res(await Promise.all(posts.map(async post =>
+ await pack(post, user, { detail: true }))));
+});