diff options
| author | ha-dai <contact@haradai.net> | 2018-05-04 02:49:46 +0900 |
|---|---|---|
| committer | ha-dai <contact@haradai.net> | 2018-05-04 02:49:46 +0900 |
| commit | f850283147072c681df1b39c57f8bd0b14f18016 (patch) | |
| tree | 63ff533c91097da2d8ca2070fc67a28f67ee33da /src/server/api/endpoints | |
| parent | Merge branch 'master' of github.com:syuilo/misskey (diff) | |
| parent | 1.7.0 (diff) | |
| download | misskey-f850283147072c681df1b39c57f8bd0b14f18016.tar.gz misskey-f850283147072c681df1b39c57f8bd0b14f18016.tar.bz2 misskey-f850283147072c681df1b39c57f8bd0b14f18016.zip | |
Merge branch 'master' of github.com:syuilo/misskey
Diffstat (limited to 'src/server/api/endpoints')
104 files changed, 6852 insertions, 0 deletions
diff --git a/src/server/api/endpoints/aggregation/posts.ts b/src/server/api/endpoints/aggregation/posts.ts new file mode 100644 index 0000000000..d348cadae9 --- /dev/null +++ b/src/server/api/endpoints/aggregation/posts.ts @@ -0,0 +1,87 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Note from '../../../../models/note'; + +/** + * Aggregate notes + */ +module.exports = params => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 365, limitErr] = $.num.optional().range(1, 365).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + const datas = await Note + .aggregate([ + { $project: { + renoteId: '$renoteId', + replyId: '$replyId', + createdAt: { $add: ['$createdAt', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$createdAt' }, + month: { $month: '$createdAt' }, + day: { $dayOfMonth: '$createdAt' } + }, + type: { + $cond: { + if: { $ne: ['$renoteId', null] }, + then: 'renote', + else: { + $cond: { + if: { $ne: ['$replyId', null] }, + then: 'reply', + else: 'note' + } + } + } + }} + }, + { $group: { _id: { + date: '$date', + type: '$type' + }, count: { $sum: 1 } } }, + { $group: { + _id: '$_id.date', + data: { $addToSet: { + type: '$_id.type', + count: '$count' + }} + } } + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + + data.notes = (data.data.filter(x => x.type == 'note')[0] || { count: 0 }).count; + data.renotes = (data.data.filter(x => x.type == 'renote')[0] || { count: 0 }).count; + data.replies = (data.data.filter(x => x.type == 'reply')[0] || { count: 0 }).count; + + delete data.data; + }); + + const graph = []; + + for (let i = 0; i < limit; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + notes: 0, + renotes: 0, + replies: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users.ts b/src/server/api/endpoints/aggregation/users.ts new file mode 100644 index 0000000000..b116c1454b --- /dev/null +++ b/src/server/api/endpoints/aggregation/users.ts @@ -0,0 +1,58 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; + +/** + * Aggregate users + */ +module.exports = params => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 365, limitErr] = $.num.optional().range(1, 365).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + const users = await User + .find({}, { + sort: { + _id: -1 + }, + fields: { + _id: false, + createdAt: true, + deletedAt: true + } + }); + + const graph = []; + + for (let i = 0; i < limit; i++) { + let dayStart = new Date(new Date().setDate(new Date().getDate() - i)); + dayStart = new Date(dayStart.setMilliseconds(0)); + dayStart = new Date(dayStart.setSeconds(0)); + dayStart = new Date(dayStart.setMinutes(0)); + dayStart = new Date(dayStart.setHours(0)); + + let dayEnd = new Date(new Date().setDate(new Date().getDate() - i)); + dayEnd = new Date(dayEnd.setMilliseconds(999)); + dayEnd = new Date(dayEnd.setSeconds(59)); + dayEnd = new Date(dayEnd.setMinutes(59)); + dayEnd = new Date(dayEnd.setHours(23)); + // day = day.getTime(); + + const total = users.filter(u => + u.createdAt < dayEnd && (u.deletedAt == null || u.deletedAt > dayEnd) + ).length; + + const created = users.filter(u => + u.createdAt < dayEnd && u.createdAt > dayStart + ).length; + + graph.push({ + total: total, + created: created + }); + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users/activity.ts b/src/server/api/endpoints/aggregation/users/activity.ts new file mode 100644 index 0000000000..9109487ac6 --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/activity.ts @@ -0,0 +1,113 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import User from '../../../../../models/user'; +import Note from '../../../../../models/note'; + +// TODO: likeやfollowも集計 + +/** + * Aggregate activity of a user + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 365, limitErr] = $.num.optional().range(1, 365).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Note + .aggregate([ + { $match: { userId: user._id } }, + { $project: { + renoteId: '$renoteId', + replyId: '$replyId', + createdAt: { $add: ['$createdAt', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$createdAt' }, + month: { $month: '$createdAt' }, + day: { $dayOfMonth: '$createdAt' } + }, + type: { + $cond: { + if: { $ne: ['$renoteId', null] }, + then: 'renote', + else: { + $cond: { + if: { $ne: ['$replyId', null] }, + then: 'reply', + else: 'note' + } + } + } + }} + }, + { $group: { _id: { + date: '$date', + type: '$type' + }, count: { $sum: 1 } } }, + { $group: { + _id: '$_id.date', + data: { $addToSet: { + type: '$_id.type', + count: '$count' + }} + } } + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + + data.notes = (data.data.filter(x => x.type == 'note')[0] || { count: 0 }).count; + data.renotes = (data.data.filter(x => x.type == 'renote')[0] || { count: 0 }).count; + data.replies = (data.data.filter(x => x.type == 'reply')[0] || { count: 0 }).count; + + delete data.data; + }); + + const graph = []; + + for (let i = 0; i < limit; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + notes: 0, + renotes: 0, + replies: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users/followers.ts b/src/server/api/endpoints/aggregation/users/followers.ts new file mode 100644 index 0000000000..dfcaf8462f --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/followers.ts @@ -0,0 +1,61 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import User from '../../../../../models/user'; +import FollowedLog from '../../../../../models/followed-log'; + +/** + * Aggregate followers of a user + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const today = new Date(); + const graph = []; + + today.setMinutes(0); + today.setSeconds(0); + today.setMilliseconds(0); + + let cursorDate = new Date(today.getTime()); + let cursorTime = cursorDate.setDate(new Date(today.getTime()).getDate() + 1); + + for (let i = 0; i < 30; i++) { + graph.push(FollowedLog.findOne({ + createdAt: { $lt: new Date(cursorTime / 1000) }, + userId: user._id + }, { + sort: { createdAt: -1 }, + }).then(log => { + cursorDate = new Date(today.getTime()); + cursorTime = cursorDate.setDate(today.getDate() - i); + + return { + date: { + year: cursorDate.getFullYear(), + month: cursorDate.getMonth() + 1, // In JavaScript, month is zero-based. + day: cursorDate.getDate() + }, + count: log ? log.count : 0 + }; + })); + } + + res(await Promise.all(graph)); +}); diff --git a/src/server/api/endpoints/aggregation/users/following.ts b/src/server/api/endpoints/aggregation/users/following.ts new file mode 100644 index 0000000000..5f826fd71c --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/following.ts @@ -0,0 +1,61 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import User from '../../../../../models/user'; +import FollowingLog from '../../../../../models/following-log'; + +/** + * Aggregate following of a user + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const today = new Date(); + const graph = []; + + today.setMinutes(0); + today.setSeconds(0); + today.setMilliseconds(0); + + let cursorDate = new Date(today.getTime()); + let cursorTime = cursorDate.setDate(new Date(today.getTime()).getDate() + 1); + + for (let i = 0; i < 30; i++) { + graph.push(FollowingLog.findOne({ + createdAt: { $lt: new Date(cursorTime / 1000) }, + userId: user._id + }, { + sort: { createdAt: -1 }, + }).then(log => { + cursorDate = new Date(today.getTime()); + cursorTime = cursorDate.setDate(today.getDate() - i); + + return { + date: { + year: cursorDate.getFullYear(), + month: cursorDate.getMonth() + 1, // In JavaScript, month is zero-based. + day: cursorDate.getDate() + }, + count: log ? log.count : 0 + }; + })); + } + + res(await Promise.all(graph)); +}); diff --git a/src/server/api/endpoints/aggregation/users/post.ts b/src/server/api/endpoints/aggregation/users/post.ts new file mode 100644 index 0000000000..11f9ef14cd --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/post.ts @@ -0,0 +1,107 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import User from '../../../../../models/user'; +import Note from '../../../../../models/note'; + +/** + * Aggregate note of a user + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Note + .aggregate([ + { $match: { userId: user._id } }, + { $project: { + renoteId: '$renoteId', + replyId: '$replyId', + createdAt: { $add: ['$createdAt', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$createdAt' }, + month: { $month: '$createdAt' }, + day: { $dayOfMonth: '$createdAt' } + }, + type: { + $cond: { + if: { $ne: ['$renoteId', null] }, + then: 'renote', + else: { + $cond: { + if: { $ne: ['$replyId', null] }, + then: 'reply', + else: 'note' + } + } + } + }} + }, + { $group: { _id: { + date: '$date', + type: '$type' + }, count: { $sum: 1 } } }, + { $group: { + _id: '$_id.date', + data: { $addToSet: { + type: '$_id.type', + count: '$count' + }} + } } + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + + data.notes = (data.data.filter(x => x.type == 'note')[0] || { count: 0 }).count; + data.renotes = (data.data.filter(x => x.type == 'renote')[0] || { count: 0 }).count; + data.replies = (data.data.filter(x => x.type == 'reply')[0] || { count: 0 }).count; + + delete data.data; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + notes: 0, + renotes: 0, + replies: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users/reaction.ts b/src/server/api/endpoints/aggregation/users/reaction.ts new file mode 100644 index 0000000000..2de2840258 --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/reaction.ts @@ -0,0 +1,80 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import User from '../../../../../models/user'; +import Reaction from '../../../../../models/note-reaction'; + +/** + * Aggregate reaction of a user + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Reaction + .aggregate([ + { $match: { userId: user._id } }, + { $project: { + createdAt: { $add: ['$createdAt', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$createdAt' }, + month: { $month: '$createdAt' }, + day: { $dayOfMonth: '$createdAt' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/app/create.ts b/src/server/api/endpoints/app/create.ts new file mode 100644 index 0000000000..553bd2381a --- /dev/null +++ b/src/server/api/endpoints/app/create.ts @@ -0,0 +1,108 @@ +/** + * Module dependencies + */ +import rndstr from 'rndstr'; +import $ from 'cafy'; +import App, { isValidNameId, pack } from '../../../../models/app'; + +/** + * @swagger + * /app/create: + * note: + * summary: Create an application + * parameters: + * - $ref: "#/parameters/AccessToken" + * - + * name: nameId + * description: Application unique name + * in: formData + * required: true + * type: string + * - + * name: name + * description: Application name + * in: formData + * required: true + * type: string + * - + * name: description + * description: Application description + * in: formData + * required: true + * type: string + * - + * name: permission + * description: Permissions that application has + * in: formData + * required: true + * type: array + * items: + * type: string + * collectionFormat: csv + * - + * name: callbackUrl + * description: URL called back after authentication + * in: formData + * required: false + * type: string + * + * responses: + * 200: + * description: Created application's information + * schema: + * $ref: "#/definitions/Application" + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Create an app + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'nameId' parameter + const [nameId, nameIdErr] = $.str.pipe(isValidNameId).get(params.nameId); + if (nameIdErr) return rej('invalid nameId param'); + + // Get 'name' parameter + const [name, nameErr] = $.str.get(params.name); + if (nameErr) return rej('invalid name param'); + + // Get 'description' parameter + const [description, descriptionErr] = $.str.get(params.description); + if (descriptionErr) return rej('invalid description param'); + + // Get 'permission' parameter + const [permission, permissionErr] = $.arr($.str).unique().get(params.permission); + if (permissionErr) return rej('invalid permission param'); + + // Get 'callbackUrl' parameter + // TODO: Check it is valid url + const [callbackUrl = null, callbackUrlErr] = $.str.optional().nullable().get(params.callbackUrl); + if (callbackUrlErr) return rej('invalid callbackUrl param'); + + // Generate secret + const secret = rndstr('a-zA-Z0-9', 32); + + // Create account + const app = await App.insert({ + createdAt: new Date(), + userId: user._id, + name: name, + nameId: nameId, + nameIdLower: nameId.toLowerCase(), + description: description, + permission: permission, + callbackUrl: callbackUrl, + secret: secret + }); + + // Response + res(await pack(app)); +}); diff --git a/src/server/api/endpoints/app/name_id/available.ts b/src/server/api/endpoints/app/name_id/available.ts new file mode 100644 index 0000000000..135bb7d2b4 --- /dev/null +++ b/src/server/api/endpoints/app/name_id/available.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import App from '../../../../../models/app'; +import { isValidNameId } from '../../../../../models/app'; + +/** + * @swagger + * /app/nameId/available: + * note: + * summary: Check available nameId on creation an application + * parameters: + * - + * name: nameId + * description: Application unique name + * in: formData + * required: true + * type: string + * + * responses: + * 200: + * description: Success + * schema: + * type: object + * properties: + * available: + * description: Whether nameId is available + * type: boolean + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Check available nameId of app + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = async (params) => new Promise(async (res, rej) => { + // Get 'nameId' parameter + const [nameId, nameIdErr] = $.str.pipe(isValidNameId).get(params.nameId); + if (nameIdErr) return rej('invalid nameId param'); + + // Get exist + const exist = await App + .count({ + nameIdLower: nameId.toLowerCase() + }, { + limit: 1 + }); + + // Reply + res({ + available: exist === 0 + }); +}); diff --git a/src/server/api/endpoints/app/show.ts b/src/server/api/endpoints/app/show.ts new file mode 100644 index 0000000000..8d742ab182 --- /dev/null +++ b/src/server/api/endpoints/app/show.ts @@ -0,0 +1,68 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import App, { pack } from '../../../../models/app'; + +/** + * @swagger + * /app/show: + * note: + * summary: Show an application's information + * description: Require appId or nameId + * parameters: + * - + * name: appId + * description: Application ID + * in: formData + * type: string + * - + * name: nameId + * description: Application unique name + * in: formData + * type: string + * + * responses: + * 200: + * description: Success + * schema: + * $ref: "#/definitions/Application" + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Show an app + */ +module.exports = (params, user, app) => new Promise(async (res, rej) => { + const isSecure = user != null && app == null; + + // Get 'appId' parameter + const [appId, appIdErr] = $.type(ID).optional().get(params.appId); + if (appIdErr) return rej('invalid appId param'); + + // Get 'nameId' parameter + const [nameId, nameIdErr] = $.str.optional().get(params.nameId); + if (nameIdErr) return rej('invalid nameId param'); + + if (appId === undefined && nameId === undefined) { + return rej('appId or nameId is required'); + } + + // Lookup app + const ap = appId !== undefined + ? await App.findOne({ _id: appId }) + : await App.findOne({ nameIdLower: nameId.toLowerCase() }); + + if (ap === null) { + return rej('app not found'); + } + + // Send response + res(await pack(ap, user, { + includeSecret: isSecure && ap.userId.equals(user._id) + })); +}); diff --git a/src/server/api/endpoints/auth/accept.ts b/src/server/api/endpoints/auth/accept.ts new file mode 100644 index 0000000000..695fbb0803 --- /dev/null +++ b/src/server/api/endpoints/auth/accept.ts @@ -0,0 +1,93 @@ +/** + * Module dependencies + */ +import rndstr from 'rndstr'; +const crypto = require('crypto'); +import $ from 'cafy'; +import App from '../../../../models/app'; +import AuthSess from '../../../../models/auth-session'; +import AccessToken from '../../../../models/access-token'; + +/** + * @swagger + * /auth/accept: + * note: + * summary: Accept a session + * parameters: + * - $ref: "#/parameters/NativeToken" + * - + * name: token + * description: Session Token + * in: formData + * required: true + * type: string + * responses: + * 204: + * description: OK + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Accept + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'token' parameter + const [token, tokenErr] = $.str.get(params.token); + if (tokenErr) return rej('invalid token param'); + + // Fetch token + const session = await AuthSess + .findOne({ token: token }); + + if (session === null) { + return rej('session not found'); + } + + // Generate access token + const accessToken = rndstr('a-zA-Z0-9', 32); + + // Fetch exist access token + const exist = await AccessToken.findOne({ + appId: session.appId, + userId: user._id, + }); + + if (exist === null) { + // Lookup app + const app = await App.findOne({ + _id: session.appId + }); + + // Generate Hash + const sha256 = crypto.createHash('sha256'); + sha256.update(accessToken + app.secret); + const hash = sha256.digest('hex'); + + // Insert access token doc + await AccessToken.insert({ + createdAt: new Date(), + appId: session.appId, + userId: user._id, + token: accessToken, + hash: hash + }); + } + + // Update session + await AuthSess.update(session._id, { + $set: { + userId: user._id + } + }); + + // Response + res(); +}); diff --git a/src/server/api/endpoints/auth/session/generate.ts b/src/server/api/endpoints/auth/session/generate.ts new file mode 100644 index 0000000000..d649a8d902 --- /dev/null +++ b/src/server/api/endpoints/auth/session/generate.ts @@ -0,0 +1,76 @@ +/** + * Module dependencies + */ +import * as uuid from 'uuid'; +import $ from 'cafy'; +import App from '../../../../../models/app'; +import AuthSess from '../../../../../models/auth-session'; +import config from '../../../../../config'; + +/** + * @swagger + * /auth/session/generate: + * note: + * summary: Generate a session + * parameters: + * - + * name: appSecret + * description: App Secret + * in: formData + * required: true + * type: string + * + * responses: + * 200: + * description: OK + * schema: + * type: object + * properties: + * token: + * type: string + * description: Session Token + * url: + * type: string + * description: Authentication form's URL + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Generate a session + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'appSecret' parameter + const [appSecret, appSecretErr] = $.str.get(params.appSecret); + if (appSecretErr) return rej('invalid appSecret param'); + + // Lookup app + const app = await App.findOne({ + secret: appSecret + }); + + if (app == null) { + return rej('app not found'); + } + + // Generate token + const token = uuid.v4(); + + // Create session token document + const doc = await AuthSess.insert({ + createdAt: new Date(), + appId: app._id, + token: token + }); + + // Response + res({ + token: doc.token, + url: `${config.auth_url}/${doc.token}` + }); +}); diff --git a/src/server/api/endpoints/auth/session/show.ts b/src/server/api/endpoints/auth/session/show.ts new file mode 100644 index 0000000000..434cc264a0 --- /dev/null +++ b/src/server/api/endpoints/auth/session/show.ts @@ -0,0 +1,70 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import AuthSess, { pack } from '../../../../../models/auth-session'; + +/** + * @swagger + * /auth/session/show: + * note: + * summary: Show a session information + * parameters: + * - + * name: token + * description: Session Token + * in: formData + * required: true + * type: string + * + * responses: + * 200: + * description: OK + * schema: + * type: object + * properties: + * createdAt: + * type: string + * format: date-time + * description: Date and time of the session creation + * appId: + * type: string + * description: Application ID + * token: + * type: string + * description: Session Token + * userId: + * type: string + * description: ID of user who create the session + * app: + * $ref: "#/definitions/Application" + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Show a session + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'token' parameter + const [token, tokenErr] = $.str.get(params.token); + if (tokenErr) return rej('invalid token param'); + + // Lookup session + const session = await AuthSess.findOne({ + token: token + }); + + if (session == null) { + return rej('session not found'); + } + + // Response + res(await pack(session, user)); +}); diff --git a/src/server/api/endpoints/auth/session/userkey.ts b/src/server/api/endpoints/auth/session/userkey.ts new file mode 100644 index 0000000000..3026b477f1 --- /dev/null +++ b/src/server/api/endpoints/auth/session/userkey.ts @@ -0,0 +1,109 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import App from '../../../../../models/app'; +import AuthSess from '../../../../../models/auth-session'; +import AccessToken from '../../../../../models/access-token'; +import { pack } from '../../../../../models/user'; + +/** + * @swagger + * /auth/session/userkey: + * note: + * summary: Get an access token(userkey) + * parameters: + * - + * name: appSecret + * description: App Secret + * in: formData + * required: true + * type: string + * - + * name: token + * description: Session Token + * in: formData + * required: true + * type: string + * + * responses: + * 200: + * description: OK + * schema: + * type: object + * properties: + * userkey: + * type: string + * description: Access Token + * user: + * $ref: "#/definitions/User" + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Generate a session + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'appSecret' parameter + const [appSecret, appSecretErr] = $.str.get(params.appSecret); + if (appSecretErr) return rej('invalid appSecret param'); + + // Lookup app + const app = await App.findOne({ + secret: appSecret + }); + + if (app == null) { + return rej('app not found'); + } + + // Get 'token' parameter + const [token, tokenErr] = $.str.get(params.token); + if (tokenErr) return rej('invalid token param'); + + // Fetch token + const session = await AuthSess + .findOne({ + token: token, + appId: app._id + }); + + if (session === null) { + return rej('session not found'); + } + + if (session.userId == null) { + return rej('this session is not allowed yet'); + } + + // Lookup access token + const accessToken = await AccessToken.findOne({ + appId: app._id, + userId: session.userId + }); + + // Delete session + + /* https://github.com/Automattic/monk/issues/178 + AuthSess.deleteOne({ + _id: session._id + }); + */ + AuthSess.remove({ + _id: session._id + }); + + // Response + res({ + accessToken: accessToken.token, + user: await pack(session.userId, null, { + detail: true + }) + }); +}); diff --git a/src/server/api/endpoints/channels.ts b/src/server/api/endpoints/channels.ts new file mode 100644 index 0000000000..ceef4b9cb9 --- /dev/null +++ b/src/server/api/endpoints/channels.ts @@ -0,0 +1,58 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../cafy-id'; +import Channel, { pack } from '../../../models/channel'; + +/** + * Get all channels + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = {} as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const channels = await Channel + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(channels.map(async channel => + await pack(channel, me)))); +}); diff --git a/src/server/api/endpoints/channels/create.ts b/src/server/api/endpoints/channels/create.ts new file mode 100644 index 0000000000..0e3c9dc5ac --- /dev/null +++ b/src/server/api/endpoints/channels/create.ts @@ -0,0 +1,35 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../../../models/channel'; +import Watching from '../../../../models/channel-watching'; +import { pack } from '../../../../models/channel'; + +/** + * Create a channel + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'title' parameter + const [title, titleErr] = $.str.range(1, 100).get(params.title); + if (titleErr) return rej('invalid title param'); + + // Create a channel + const channel = await Channel.insert({ + createdAt: new Date(), + userId: user._id, + title: title, + index: 0, + watchingCount: 1 + }); + + // Response + res(await pack(channel)); + + // Create Watching + await Watching.insert({ + createdAt: new Date(), + userId: user._id, + channelId: channel._id + }); +}); diff --git a/src/server/api/endpoints/channels/notes.ts b/src/server/api/endpoints/channels/notes.ts new file mode 100644 index 0000000000..463152e74a --- /dev/null +++ b/src/server/api/endpoints/channels/notes.ts @@ -0,0 +1,74 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import { default as Channel, IChannel } from '../../../../models/channel'; +import Note, { pack } from '../../../../models/note'; + +/** + * Show a notes of a channel + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 1000, limitErr] = $.num.optional().range(1, 1000).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Get 'channelId' parameter + const [channelId, channelIdErr] = $.type(ID).get(params.channelId); + if (channelIdErr) return rej('invalid channelId param'); + + // Fetch channel + const channel: IChannel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + channelId: channel._id + } as any; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + //#endregion Construct query + + // Issue query + const notes = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(notes.map(async (note) => + await pack(note, user) + ))); +}); diff --git a/src/server/api/endpoints/channels/show.ts b/src/server/api/endpoints/channels/show.ts new file mode 100644 index 0000000000..1bba63d490 --- /dev/null +++ b/src/server/api/endpoints/channels/show.ts @@ -0,0 +1,26 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Channel, { IChannel, pack } from '../../../../models/channel'; + +/** + * Show a channel + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channelId' parameter + const [channelId, channelIdErr] = $.type(ID).get(params.channelId); + if (channelIdErr) return rej('invalid channelId param'); + + // Fetch channel + const channel: IChannel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + // Serialize + res(await pack(channel, user)); +}); diff --git a/src/server/api/endpoints/channels/unwatch.ts b/src/server/api/endpoints/channels/unwatch.ts new file mode 100644 index 0000000000..f7dddff461 --- /dev/null +++ b/src/server/api/endpoints/channels/unwatch.ts @@ -0,0 +1,56 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Channel from '../../../../models/channel'; +import Watching from '../../../../models/channel-watching'; + +/** + * Unwatch a channel + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channelId' parameter + const [channelId, channelIdErr] = $.type(ID).get(params.channelId); + if (channelIdErr) return rej('invalid channelId param'); + + //#region Fetch channel + const channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + //#endregion + + //#region Check whether not watching + const exist = await Watching.findOne({ + userId: user._id, + channelId: channel._id, + deletedAt: { $exists: false } + }); + + if (exist === null) { + return rej('already not watching'); + } + //#endregion + + // Delete watching + await Watching.update({ + _id: exist._id + }, { + $set: { + deletedAt: new Date() + } + }); + + // Send response + res(); + + // Decrement watching count + Channel.update(channel._id, { + $inc: { + watchingCount: -1 + } + }); +}); diff --git a/src/server/api/endpoints/channels/watch.ts b/src/server/api/endpoints/channels/watch.ts new file mode 100644 index 0000000000..34243ff68b --- /dev/null +++ b/src/server/api/endpoints/channels/watch.ts @@ -0,0 +1,54 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Channel from '../../../../models/channel'; +import Watching from '../../../../models/channel-watching'; + +/** + * Watch a channel + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channelId' parameter + const [channelId, channelIdErr] = $.type(ID).get(params.channelId); + if (channelIdErr) return rej('invalid channelId param'); + + //#region Fetch channel + const channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + //#endregion + + //#region Check whether already watching + const exist = await Watching.findOne({ + userId: user._id, + channelId: channel._id, + deletedAt: { $exists: false } + }); + + if (exist !== null) { + return rej('already watching'); + } + //#endregion + + // Create Watching + await Watching.insert({ + createdAt: new Date(), + userId: user._id, + channelId: channel._id + }); + + // Send response + res(); + + // Increment watching count + Channel.update(channel._id, { + $inc: { + watchingCount: 1 + } + }); +}); diff --git a/src/server/api/endpoints/drive.ts b/src/server/api/endpoints/drive.ts new file mode 100644 index 0000000000..d77ab2bbb0 --- /dev/null +++ b/src/server/api/endpoints/drive.ts @@ -0,0 +1,37 @@ +/** + * Module dependencies + */ +import DriveFile from '../../../models/drive-file'; + +/** + * Get drive information + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Calculate drive usage + const usage = ((await DriveFile + .aggregate([ + { $match: { 'metadata.userId': user._id } }, + { + $project: { + length: true + } + }, + { + $group: { + _id: null, + usage: { $sum: '$length' } + } + } + ]))[0] || { + usage: 0 + }).usage; + + res({ + capacity: user.driveCapacity, + usage: usage + }); +}); diff --git a/src/server/api/endpoints/drive/files.ts b/src/server/api/endpoints/drive/files.ts new file mode 100644 index 0000000000..ab4b18cef4 --- /dev/null +++ b/src/server/api/endpoints/drive/files.ts @@ -0,0 +1,68 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import DriveFile, { pack } from '../../../../models/drive-file'; + +/** + * Get drive files + */ +module.exports = async (params, user, app) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) throw 'invalid limit param'; + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) throw 'invalid sinceId param'; + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) throw 'invalid untilId param'; + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + throw 'cannot set sinceId and untilId'; + } + + // Get 'folderId' parameter + const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId); + if (folderIdErr) throw 'invalid folderId param'; + + // Get 'type' parameter + const [type, typeErr] = $.str.optional().match(/^[a-zA-Z\/\-\*]+$/).get(params.type); + if (typeErr) throw 'invalid type param'; + + // Construct query + const sort = { + _id: -1 + }; + const query = { + 'metadata.userId': user._id, + 'metadata.folderId': folderId + } as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + if (type) { + query.contentType = new RegExp(`^${type.replace(/\*/g, '.+?')}$`); + } + + // Issue query + const files = await DriveFile + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + const _files = await Promise.all(files.map(file => pack(file))); + return _files; +}; diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts new file mode 100644 index 0000000000..e9348e4e2f --- /dev/null +++ b/src/server/api/endpoints/drive/files/create.ts @@ -0,0 +1,46 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import { validateFileName, pack } from '../../../../../models/drive-file'; +import create from '../../../../../services/drive/add-file'; + +/** + * Create a file + */ +module.exports = async (file, params, user): Promise<any> => { + if (file == null) { + throw 'file is required'; + } + + // Get 'name' parameter + let name = file.originalname; + if (name !== undefined && name !== null) { + name = name.trim(); + if (name.length === 0) { + name = null; + } else if (name === 'blob') { + name = null; + } else if (!validateFileName(name)) { + throw 'invalid name'; + } + } else { + name = null; + } + + // Get 'folderId' parameter + const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId); + if (folderIdErr) throw 'invalid folderId param'; + + try { + // Create file + const driveFile = await create(user, file.path, name, null, folderId); + + // Serialize + return pack(driveFile); + } catch (e) { + console.error(e); + + throw e; + } +}; diff --git a/src/server/api/endpoints/drive/files/find.ts b/src/server/api/endpoints/drive/files/find.ts new file mode 100644 index 0000000000..98165990fe --- /dev/null +++ b/src/server/api/endpoints/drive/files/find.ts @@ -0,0 +1,30 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import DriveFile, { pack } from '../../../../../models/drive-file'; + +/** + * Find a file(s) + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name, nameErr] = $.str.get(params.name); + if (nameErr) return rej('invalid name param'); + + // Get 'folderId' parameter + const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId); + if (folderIdErr) return rej('invalid folderId param'); + + // Issue query + const files = await DriveFile + .find({ + filename: name, + 'metadata.userId': user._id, + 'metadata.folderId': folderId + }); + + // Serialize + res(await Promise.all(files.map(async file => + await pack(file)))); +}); diff --git a/src/server/api/endpoints/drive/files/show.ts b/src/server/api/endpoints/drive/files/show.ts new file mode 100644 index 0000000000..c7efda7ab0 --- /dev/null +++ b/src/server/api/endpoints/drive/files/show.ts @@ -0,0 +1,32 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import DriveFile, { pack } from '../../../../../models/drive-file'; + +/** + * Show a file + */ +module.exports = async (params, user) => { + // Get 'fileId' parameter + const [fileId, fileIdErr] = $.type(ID).get(params.fileId); + if (fileIdErr) throw 'invalid fileId param'; + + // Fetch file + const file = await DriveFile + .findOne({ + _id: fileId, + 'metadata.userId': user._id + }); + + if (file === null) { + throw 'file-not-found'; + } + + // Serialize + const _file = await pack(file, { + detail: true + }); + + return _file; +}; diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts new file mode 100644 index 0000000000..12fa8e025d --- /dev/null +++ b/src/server/api/endpoints/drive/files/update.ts @@ -0,0 +1,71 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import DriveFolder from '../../../../../models/drive-folder'; +import DriveFile, { validateFileName, pack } from '../../../../../models/drive-file'; +import { publishDriveStream } from '../../../../../publishers/stream'; + +/** + * Update a file + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'fileId' parameter + const [fileId, fileIdErr] = $.type(ID).get(params.fileId); + if (fileIdErr) return rej('invalid fileId param'); + + // Fetch file + const file = await DriveFile + .findOne({ + _id: fileId, + 'metadata.userId': user._id + }); + + if (file === null) { + return rej('file-not-found'); + } + + // Get 'name' parameter + const [name, nameErr] = $.str.optional().pipe(validateFileName).get(params.name); + if (nameErr) return rej('invalid name param'); + if (name) file.filename = name; + + // Get 'folderId' parameter + const [folderId, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId); + if (folderIdErr) return rej('invalid folderId param'); + + if (folderId !== undefined) { + if (folderId === null) { + file.metadata.folderId = null; + } else { + // Fetch folder + const folder = await DriveFolder + .findOne({ + _id: folderId, + userId: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + file.metadata.folderId = folder._id; + } + } + + await DriveFile.update(file._id, { + $set: { + filename: file.filename, + 'metadata.folderId': file.metadata.folderId + } + }); + + // Serialize + const fileObj = await pack(file); + + // Response + res(fileObj); + + // Publish file_updated event + publishDriveStream(user._id, 'file_updated', fileObj); +}); diff --git a/src/server/api/endpoints/drive/files/upload_from_url.ts b/src/server/api/endpoints/drive/files/upload_from_url.ts new file mode 100644 index 0000000000..c012f0d3c9 --- /dev/null +++ b/src/server/api/endpoints/drive/files/upload_from_url.ts @@ -0,0 +1,22 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import { pack } from '../../../../../models/drive-file'; +import uploadFromUrl from '../../../../../services/drive/upload-from-url'; + +/** + * Create a file from a URL + */ +module.exports = async (params, user): Promise<any> => { + // Get 'url' parameter + // TODO: Validate this url + const [url, urlErr] = $.str.get(params.url); + if (urlErr) throw 'invalid url param'; + + // Get 'folderId' parameter + const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId); + if (folderIdErr) throw 'invalid folderId param'; + + return pack(await uploadFromUrl(url, user, folderId)); +}; diff --git a/src/server/api/endpoints/drive/folders.ts b/src/server/api/endpoints/drive/folders.ts new file mode 100644 index 0000000000..bc6c50eb99 --- /dev/null +++ b/src/server/api/endpoints/drive/folders.ts @@ -0,0 +1,61 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import DriveFolder, { pack } from '../../../../models/drive-folder'; + +/** + * Get drive folders + */ +module.exports = (params, user, app) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Get 'folderId' parameter + const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId); + if (folderIdErr) return rej('invalid folderId param'); + + // Construct query + const sort = { + _id: -1 + }; + const query = { + userId: user._id, + parentId: folderId + } as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const folders = await DriveFolder + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(folders.map(async folder => + await pack(folder)))); +}); diff --git a/src/server/api/endpoints/drive/folders/create.ts b/src/server/api/endpoints/drive/folders/create.ts new file mode 100644 index 0000000000..62e3b6f6e8 --- /dev/null +++ b/src/server/api/endpoints/drive/folders/create.ts @@ -0,0 +1,51 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder'; +import { publishDriveStream } from '../../../../../publishers/stream'; + +/** + * Create drive folder + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name = '無題のフォルダー', nameErr] = $.str.optional().pipe(isValidFolderName).get(params.name); + if (nameErr) return rej('invalid name param'); + + // Get 'parentId' parameter + const [parentId = null, parentIdErr] = $.type(ID).optional().nullable().get(params.parentId); + if (parentIdErr) return rej('invalid parentId param'); + + // If the parent folder is specified + let parent = null; + if (parentId) { + // Fetch parent folder + parent = await DriveFolder + .findOne({ + _id: parentId, + userId: user._id + }); + + if (parent === null) { + return rej('parent-not-found'); + } + } + + // Create folder + const folder = await DriveFolder.insert({ + createdAt: new Date(), + name: name, + parentId: parent !== null ? parent._id : null, + userId: user._id + }); + + // Serialize + const folderObj = await pack(folder); + + // Response + res(folderObj); + + // Publish folder_created event + publishDriveStream(user._id, 'folder_created', folderObj); +}); diff --git a/src/server/api/endpoints/drive/folders/find.ts b/src/server/api/endpoints/drive/folders/find.ts new file mode 100644 index 0000000000..9703d9e99d --- /dev/null +++ b/src/server/api/endpoints/drive/folders/find.ts @@ -0,0 +1,29 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import DriveFolder, { pack } from '../../../../../models/drive-folder'; + +/** + * Find a folder(s) + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name, nameErr] = $.str.get(params.name); + if (nameErr) return rej('invalid name param'); + + // Get 'parentId' parameter + const [parentId = null, parentIdErr] = $.type(ID).optional().nullable().get(params.parentId); + if (parentIdErr) return rej('invalid parentId param'); + + // Issue query + const folders = await DriveFolder + .find({ + name: name, + userId: user._id, + parentId: parentId + }); + + // Serialize + res(await Promise.all(folders.map(folder => pack(folder)))); +}); diff --git a/src/server/api/endpoints/drive/folders/show.ts b/src/server/api/endpoints/drive/folders/show.ts new file mode 100644 index 0000000000..44f1889001 --- /dev/null +++ b/src/server/api/endpoints/drive/folders/show.ts @@ -0,0 +1,30 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import DriveFolder, { pack } from '../../../../../models/drive-folder'; + +/** + * Show a folder + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'folderId' parameter + const [folderId, folderIdErr] = $.type(ID).get(params.folderId); + if (folderIdErr) return rej('invalid folderId param'); + + // Get folder + const folder = await DriveFolder + .findOne({ + _id: folderId, + userId: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + // Serialize + res(await pack(folder, { + detail: true + })); +}); diff --git a/src/server/api/endpoints/drive/folders/update.ts b/src/server/api/endpoints/drive/folders/update.ts new file mode 100644 index 0000000000..e24d8a14cd --- /dev/null +++ b/src/server/api/endpoints/drive/folders/update.ts @@ -0,0 +1,95 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder'; +import { publishDriveStream } from '../../../../../publishers/stream'; + +/** + * Update a folder + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'folderId' parameter + const [folderId, folderIdErr] = $.type(ID).get(params.folderId); + if (folderIdErr) return rej('invalid folderId param'); + + // Fetch folder + const folder = await DriveFolder + .findOne({ + _id: folderId, + userId: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + // Get 'name' parameter + const [name, nameErr] = $.str.optional().pipe(isValidFolderName).get(params.name); + if (nameErr) return rej('invalid name param'); + if (name) folder.name = name; + + // Get 'parentId' parameter + const [parentId, parentIdErr] = $.type(ID).optional().nullable().get(params.parentId); + if (parentIdErr) return rej('invalid parentId param'); + if (parentId !== undefined) { + if (parentId === null) { + folder.parentId = null; + } else { + // Get parent folder + const parent = await DriveFolder + .findOne({ + _id: parentId, + userId: user._id + }); + + if (parent === null) { + return rej('parent-folder-not-found'); + } + + // Check if the circular reference will occur + async function checkCircle(folderId) { + // Fetch folder + const folder2 = await DriveFolder.findOne({ + _id: folderId + }, { + _id: true, + parentId: true + }); + + if (folder2._id.equals(folder._id)) { + return true; + } else if (folder2.parentId) { + return await checkCircle(folder2.parentId); + } else { + return false; + } + } + + if (parent.parentId !== null) { + if (await checkCircle(parent.parentId)) { + return rej('detected-circular-definition'); + } + } + + folder.parentId = parent._id; + } + } + + // Update + DriveFolder.update(folder._id, { + $set: { + name: folder.name, + parentId: folder.parentId + } + }); + + // Serialize + const folderObj = await pack(folder); + + // Response + res(folderObj); + + // Publish folder_updated event + publishDriveStream(user._id, 'folder_updated', folderObj); +}); diff --git a/src/server/api/endpoints/drive/stream.ts b/src/server/api/endpoints/drive/stream.ts new file mode 100644 index 0000000000..8cb3a99b42 --- /dev/null +++ b/src/server/api/endpoints/drive/stream.ts @@ -0,0 +1,63 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import DriveFile, { pack } from '../../../../models/drive-file'; + +/** + * Get drive stream + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Get 'type' parameter + const [type, typeErr] = $.str.optional().match(/^[a-zA-Z\/\-\*]+$/).get(params.type); + if (typeErr) return rej('invalid type param'); + + // Construct query + const sort = { + _id: -1 + }; + const query = { + 'metadata.userId': user._id + } as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + if (type) { + query.contentType = new RegExp(`^${type.replace(/\*/g, '.+?')}$`); + } + + // Issue query + const files = await DriveFile + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(files.map(async file => + await pack(file)))); +}); diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts new file mode 100644 index 0000000000..766a8c03d0 --- /dev/null +++ b/src/server/api/endpoints/following/create.ts @@ -0,0 +1,53 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import User from '../../../../models/user'; +import Following from '../../../../models/following'; +import create from '../../../../services/following/create'; + +/** + * Follow a user + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const follower = user; + + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // 自分自身 + if (user._id.equals(userId)) { + return rej('followee is yourself'); + } + + // Get followee + const followee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + profile: false + } + }); + + if (followee === null) { + return rej('user not found'); + } + + // Check if already following + const exist = await Following.findOne({ + followerId: follower._id, + followeeId: followee._id + }); + + if (exist !== null) { + return rej('already following'); + } + + // Create following + create(follower, followee); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/following/delete.ts b/src/server/api/endpoints/following/delete.ts new file mode 100644 index 0000000000..396b19a6f6 --- /dev/null +++ b/src/server/api/endpoints/following/delete.ts @@ -0,0 +1,53 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import User from '../../../../models/user'; +import Following from '../../../../models/following'; +import deleteFollowing from '../../../../services/following/delete'; + +/** + * Unfollow a user + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const follower = user; + + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Check if the followee is yourself + if (user._id.equals(userId)) { + return rej('followee is yourself'); + } + + // Get followee + const followee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + 'profile': false + } + }); + + if (followee === null) { + return rej('user not found'); + } + + // Check not following + const exist = await Following.findOne({ + followerId: follower._id, + followeeId: followee._id + }); + + if (exist === null) { + return rej('already not following'); + } + + // Delete following + deleteFollowing(follower, followee); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/following/stalk.ts b/src/server/api/endpoints/following/stalk.ts new file mode 100644 index 0000000000..f0bc8cbdfc --- /dev/null +++ b/src/server/api/endpoints/following/stalk.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Following from '../../../../models/following'; + +/** + * Stalk a user + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const follower = user; + + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Fetch following + const following = await Following.findOne({ + followerId: follower._id, + followeeId: userId + }); + + if (following === null) { + return rej('following not found'); + } + + // Stalk + await Following.update({ _id: following._id }, { + $set: { + stalk: true + } + }); + + // Send response + res(); + + // TODO: イベント +}); diff --git a/src/server/api/endpoints/following/unstalk.ts b/src/server/api/endpoints/following/unstalk.ts new file mode 100644 index 0000000000..0d0a018c34 --- /dev/null +++ b/src/server/api/endpoints/following/unstalk.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Following from '../../../../models/following'; + +/** + * Unstalk a user + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const follower = user; + + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Fetch following + const following = await Following.findOne({ + followerId: follower._id, + followeeId: userId + }); + + if (following === null) { + return rej('following not found'); + } + + // Stalk + await Following.update({ _id: following._id }, { + $set: { + stalk: false + } + }); + + // Send response + res(); + + // TODO: イベント +}); diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts new file mode 100644 index 0000000000..379c3c4d88 --- /dev/null +++ b/src/server/api/endpoints/i.ts @@ -0,0 +1,24 @@ +/** + * Module dependencies + */ +import User, { pack } from '../../../models/user'; + +/** + * Show myself + */ +module.exports = (params, user, app) => new Promise(async (res, rej) => { + const isSecure = user != null && app == null; + + // Serialize + res(await pack(user, user, { + detail: true, + includeSecrets: isSecure + })); + + // Update lastUsedAt + User.update({ _id: user._id }, { + $set: { + lastUsedAt: new Date() + } + }); +}); diff --git a/src/server/api/endpoints/i/2fa/done.ts b/src/server/api/endpoints/i/2fa/done.ts new file mode 100644 index 0000000000..1a2706aa84 --- /dev/null +++ b/src/server/api/endpoints/i/2fa/done.ts @@ -0,0 +1,37 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as speakeasy from 'speakeasy'; +import User from '../../../../../models/user'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'token' parameter + const [token, tokenErr] = $.str.get(params.token); + if (tokenErr) return rej('invalid token param'); + + const _token = token.replace(/\s/g, ''); + + if (user.twoFactorTempSecret == null) { + return rej('二段階認証の設定が開始されていません'); + } + + const verified = (speakeasy as any).totp.verify({ + secret: user.twoFactorTempSecret, + encoding: 'base32', + token: _token + }); + + if (!verified) { + return rej('not verified'); + } + + await User.update(user._id, { + $set: { + 'twoFactorSecret': user.twoFactorTempSecret, + 'twoFactorEnabled': true + } + }); + + res(); +}); diff --git a/src/server/api/endpoints/i/2fa/register.ts b/src/server/api/endpoints/i/2fa/register.ts new file mode 100644 index 0000000000..d314e1a280 --- /dev/null +++ b/src/server/api/endpoints/i/2fa/register.ts @@ -0,0 +1,48 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import * as QRCode from 'qrcode'; +import User from '../../../../../models/user'; +import config from '../../../../../config'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'password' parameter + const [password, passwordErr] = $.str.get(params.password); + if (passwordErr) return rej('invalid password param'); + + // Compare password + const same = await bcrypt.compare(password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate user's secret key + const secret = speakeasy.generateSecret({ + length: 32 + }); + + await User.update(user._id, { + $set: { + twoFactorTempSecret: secret.base32 + } + }); + + // Get the data URL of the authenticator URL + QRCode.toDataURL(speakeasy.otpauthURL({ + secret: secret.base32, + encoding: 'base32', + label: user.username, + issuer: config.host + }), (err, data_url) => { + res({ + qr: data_url, + secret: secret.base32, + label: user.username, + issuer: config.host + }); + }); +}); diff --git a/src/server/api/endpoints/i/2fa/unregister.ts b/src/server/api/endpoints/i/2fa/unregister.ts new file mode 100644 index 0000000000..336a3564ab --- /dev/null +++ b/src/server/api/endpoints/i/2fa/unregister.ts @@ -0,0 +1,28 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../../../../models/user'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'password' parameter + const [password, passwordErr] = $.str.get(params.password); + if (passwordErr) return rej('invalid password param'); + + // Compare password + const same = await bcrypt.compare(password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + await User.update(user._id, { + $set: { + 'twoFactorSecret': null, + 'twoFactorEnabled': false + } + }); + + res(); +}); diff --git a/src/server/api/endpoints/i/authorized_apps.ts b/src/server/api/endpoints/i/authorized_apps.ts new file mode 100644 index 0000000000..d15bd67bf2 --- /dev/null +++ b/src/server/api/endpoints/i/authorized_apps.ts @@ -0,0 +1,39 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import AccessToken from '../../../../models/access-token'; +import { pack } from '../../../../models/app'; + +/** + * Get authorized apps of my account + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort = 'desc', sortError] = $.str.optional().or('desc asc').get(params.sort); + if (sortError) return rej('invalid sort param'); + + // Get tokens + const tokens = await AccessToken + .find({ + userId: user._id + }, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }); + + // Serialize + res(await Promise.all(tokens.map(async token => + await pack(token.appId)))); +}); diff --git a/src/server/api/endpoints/i/change_password.ts b/src/server/api/endpoints/i/change_password.ts new file mode 100644 index 0000000000..a1a1a43406 --- /dev/null +++ b/src/server/api/endpoints/i/change_password.ts @@ -0,0 +1,38 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../../../models/user'; + +/** + * Change password + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'currentPasword' parameter + const [currentPassword, currentPasswordErr] = $.str.get(params.currentPasword); + if (currentPasswordErr) return rej('invalid currentPasword param'); + + // Get 'newPassword' parameter + const [newPassword, newPasswordErr] = $.str.get(params.newPassword); + if (newPasswordErr) return rej('invalid newPassword param'); + + // Compare password + const same = await bcrypt.compare(currentPassword, user.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(newPassword, salt); + + await User.update(user._id, { + $set: { + 'password': hash + } + }); + + res(); +}); diff --git a/src/server/api/endpoints/i/favorites.ts b/src/server/api/endpoints/i/favorites.ts new file mode 100644 index 0000000000..23517baaff --- /dev/null +++ b/src/server/api/endpoints/i/favorites.ts @@ -0,0 +1,53 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Favorite, { pack } from '../../../../models/favorite'; + +/** + * Get favorited notes + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + const query = { + userId: user._id + } as any; + + const sort = { + _id: -1 + }; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Get favorites + const favorites = await Favorite + .find(query, { limit, sort }); + + // Serialize + res(await Promise.all(favorites.map(favorite => pack(favorite, user)))); +}); diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts new file mode 100644 index 0000000000..50ed9b27e8 --- /dev/null +++ b/src/server/api/endpoints/i/notifications.ts @@ -0,0 +1,106 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Notification from '../../../../models/notification'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/notification'; +import { getFriendIds } from '../../common/get-friends'; +import read from '../../common/read-notification'; + +/** + * Get notifications + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'following' parameter + const [following = false, followingError] = + $.bool.optional().get(params.following); + if (followingError) return rej('invalid following param'); + + // Get 'markAsRead' parameter + const [markAsRead = true, markAsReadErr] = $.bool.optional().get(params.markAsRead); + if (markAsReadErr) return rej('invalid markAsRead param'); + + // Get 'type' parameter + const [type, typeErr] = $.arr($.str).optional().unique().get(params.type); + if (typeErr) return rej('invalid type param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + const mute = await Mute.find({ + muterId: user._id, + deletedAt: { $exists: false } + }); + + const query = { + notifieeId: user._id, + $and: [{ + notifierId: { + $nin: mute.map(m => m.muteeId) + } + }] + } as any; + + const sort = { + _id: -1 + }; + + if (following) { + // ID list of the user itself and other users who the user follows + const followingIds = await getFriendIds(user._id); + + query.$and.push({ + notifierId: { + $in: followingIds + } + }); + } + + if (type) { + query.type = { + $in: type + }; + } + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const notifications = await Notification + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(notifications.map(async notification => + await pack(notification)))); + + // Mark as read all + if (notifications.length > 0 && markAsRead) { + read(user._id, notifications); + } +}); diff --git a/src/server/api/endpoints/i/pin.ts b/src/server/api/endpoints/i/pin.ts new file mode 100644 index 0000000000..423f0ac4ae --- /dev/null +++ b/src/server/api/endpoints/i/pin.ts @@ -0,0 +1,40 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import User from '../../../../models/user'; +import Note from '../../../../models/note'; +import { pack } from '../../../../models/user'; + +/** + * Pin note + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); + if (noteIdErr) return rej('invalid noteId param'); + + // Fetch pinee + const note = await Note.findOne({ + _id: noteId, + userId: user._id + }); + + if (note === null) { + return rej('note not found'); + } + + await User.update(user._id, { + $set: { + pinnedNoteId: note._id + } + }); + + // Serialize + const iObj = await pack(user, user, { + detail: true + }); + + // Send response + res(iObj); +}); diff --git a/src/server/api/endpoints/i/regenerate_token.ts b/src/server/api/endpoints/i/regenerate_token.ts new file mode 100644 index 0000000000..6e1e571297 --- /dev/null +++ b/src/server/api/endpoints/i/regenerate_token.ts @@ -0,0 +1,38 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../../../models/user'; +import event from '../../../../publishers/stream'; +import generateUserToken from '../../common/generate-native-user-token'; + +/** + * Regenerate native token + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'password' parameter + const [password, passwordErr] = $.str.get(params.password); + if (passwordErr) return rej('invalid password param'); + + // Compare password + const same = await bcrypt.compare(password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate secret + const secret = generateUserToken(); + + await User.update(user._id, { + $set: { + 'token': secret + } + }); + + res(); + + // Publish event + event(user._id, 'my_token_regenerated'); +}); diff --git a/src/server/api/endpoints/i/signin_history.ts b/src/server/api/endpoints/i/signin_history.ts new file mode 100644 index 0000000000..63a74b41b1 --- /dev/null +++ b/src/server/api/endpoints/i/signin_history.ts @@ -0,0 +1,57 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Signin, { pack } from '../../../../models/signin'; + +/** + * Get signin history of my account + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + const query = { + userId: user._id + } as any; + + const sort = { + _id: -1 + }; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const history = await Signin + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(history.map(async record => + await pack(record)))); +}); diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts new file mode 100644 index 0000000000..6ba4729951 --- /dev/null +++ b/src/server/api/endpoints/i/update.ts @@ -0,0 +1,77 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import User, { isValidName, isValidDescription, isValidLocation, isValidBirthday, pack } from '../../../../models/user'; +import event from '../../../../publishers/stream'; + +/** + * Update myself + */ +module.exports = async (params, user, app) => new Promise(async (res, rej) => { + const isSecure = user != null && app == null; + + // Get 'name' parameter + const [name, nameErr] = $.str.optional().nullable().pipe(isValidName).get(params.name); + if (nameErr) return rej('invalid name param'); + if (name) user.name = name; + + // Get 'description' parameter + const [description, descriptionErr] = $.str.optional().nullable().pipe(isValidDescription).get(params.description); + if (descriptionErr) return rej('invalid description param'); + if (description !== undefined) user.description = description; + + // Get 'location' parameter + const [location, locationErr] = $.str.optional().nullable().pipe(isValidLocation).get(params.location); + if (locationErr) return rej('invalid location param'); + if (location !== undefined) user.profile.location = location; + + // Get 'birthday' parameter + const [birthday, birthdayErr] = $.str.optional().nullable().pipe(isValidBirthday).get(params.birthday); + if (birthdayErr) return rej('invalid birthday param'); + if (birthday !== undefined) user.profile.birthday = birthday; + + // Get 'avatarId' parameter + const [avatarId, avatarIdErr] = $.type(ID).optional().get(params.avatarId); + if (avatarIdErr) return rej('invalid avatarId param'); + if (avatarId) user.avatarId = avatarId; + + // Get 'bannerId' parameter + const [bannerId, bannerIdErr] = $.type(ID).optional().get(params.bannerId); + if (bannerIdErr) return rej('invalid bannerId param'); + if (bannerId) user.bannerId = bannerId; + + // Get 'isBot' parameter + const [isBot, isBotErr] = $.bool.optional().get(params.isBot); + if (isBotErr) return rej('invalid isBot param'); + if (isBot != null) user.isBot = isBot; + + // Get 'autoWatch' parameter + const [autoWatch, autoWatchErr] = $.bool.optional().get(params.autoWatch); + if (autoWatchErr) return rej('invalid autoWatch param'); + if (autoWatch != null) user.settings.autoWatch = autoWatch; + + await User.update(user._id, { + $set: { + name: user.name, + description: user.description, + avatarId: user.avatarId, + bannerId: user.bannerId, + profile: user.profile, + isBot: user.isBot, + settings: user.settings + } + }); + + // Serialize + const iObj = await pack(user, user, { + detail: true, + includeSecrets: isSecure + }); + + // Send response + res(iObj); + + // Publish i updated event + event(user._id, 'i_updated', iObj); +}); diff --git a/src/server/api/endpoints/i/update_client_setting.ts b/src/server/api/endpoints/i/update_client_setting.ts new file mode 100644 index 0000000000..e91d7565fd --- /dev/null +++ b/src/server/api/endpoints/i/update_client_setting.ts @@ -0,0 +1,34 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import event from '../../../../publishers/stream'; + +/** + * Update myself + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name, nameErr] = $.str.get(params.name); + if (nameErr) return rej('invalid name param'); + + // Get 'value' parameter + const [value, valueErr] = $.any.nullable().get(params.value); + if (valueErr) return rej('invalid value param'); + + const x = {}; + x[`clientSettings.${name}`] = value; + + await User.update(user._id, { + $set: x + }); + + res(); + + // Publish event + event(user._id, 'clientSettingUpdated', { + key: name, + value + }); +}); diff --git a/src/server/api/endpoints/i/update_home.ts b/src/server/api/endpoints/i/update_home.ts new file mode 100644 index 0000000000..8ce551957e --- /dev/null +++ b/src/server/api/endpoints/i/update_home.ts @@ -0,0 +1,62 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import event from '../../../../publishers/stream'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'home' parameter + const [home, homeErr] = $.arr( + $.obj.strict() + .have('name', $.str) + .have('id', $.str) + .have('place', $.str) + .have('data', $.obj)) + .optional() + .get(params.home); + if (homeErr) return rej('invalid home param'); + + // Get 'id' parameter + const [id, idErr] = $.str.optional().get(params.id); + if (idErr) return rej('invalid id param'); + + // Get 'data' parameter + const [data, dataErr] = $.obj.optional().get(params.data); + if (dataErr) return rej('invalid data param'); + + if (home) { + await User.update(user._id, { + $set: { + 'clientSettings.home': home + } + }); + + res(); + + event(user._id, 'home_updated', { + home + }); + } else { + if (id == null && data == null) return rej('you need to set id and data params if home param unset'); + + const _home = user.clientSettings.home; + const widget = _home.find(w => w.id == id); + + if (widget == null) return rej('widget not found'); + + widget.data = data; + + await User.update(user._id, { + $set: { + 'clientSettings.home': _home + } + }); + + res(); + + event(user._id, 'home_updated', { + id, data + }); + } +}); diff --git a/src/server/api/endpoints/i/update_mobile_home.ts b/src/server/api/endpoints/i/update_mobile_home.ts new file mode 100644 index 0000000000..d79a77072b --- /dev/null +++ b/src/server/api/endpoints/i/update_mobile_home.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import event from '../../../../publishers/stream'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'home' parameter + const [home, homeErr] = $.arr( + $.obj.strict() + .have('name', $.str) + .have('id', $.str) + .have('data', $.obj)) + .optional().get(params.home); + if (homeErr) return rej('invalid home param'); + + // Get 'id' parameter + const [id, idErr] = $.str.optional().get(params.id); + if (idErr) return rej('invalid id param'); + + // Get 'data' parameter + const [data, dataErr] = $.obj.optional().get(params.data); + if (dataErr) return rej('invalid data param'); + + if (home) { + await User.update(user._id, { + $set: { + 'clientSettings.mobileHome': home + } + }); + + res(); + + event(user._id, 'mobile_home_updated', { + home + }); + } else { + if (id == null && data == null) return rej('you need to set id and data params if home param unset'); + + const _home = user.clientSettings.mobileHome || []; + const widget = _home.find(w => w.id == id); + + if (widget == null) return rej('widget not found'); + + widget.data = data; + + await User.update(user._id, { + $set: { + 'clientSettings.mobileHome': _home + } + }); + + res(); + + event(user._id, 'mobile_home_updated', { + id, data + }); + } +}); diff --git a/src/server/api/endpoints/messaging/history.ts b/src/server/api/endpoints/messaging/history.ts new file mode 100644 index 0000000000..ec97642f17 --- /dev/null +++ b/src/server/api/endpoints/messaging/history.ts @@ -0,0 +1,39 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import History from '../../../../models/messaging-history'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/messaging-message'; + +/** + * Show messaging history + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + const mute = await Mute.find({ + muterId: user._id, + deletedAt: { $exists: false } + }); + + // Get history + const history = await History + .find({ + userId: user._id, + partnerId: { + $nin: mute.map(m => m.muteeId) + } + }, { + limit: limit, + sort: { + updatedAt: -1 + } + }); + + // Serialize + res(await Promise.all(history.map(async h => + await pack(h.messageId, user)))); +}); diff --git a/src/server/api/endpoints/messaging/messages.ts b/src/server/api/endpoints/messaging/messages.ts new file mode 100644 index 0000000000..0338aba68a --- /dev/null +++ b/src/server/api/endpoints/messaging/messages.ts @@ -0,0 +1,102 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Message from '../../../../models/messaging-message'; +import User from '../../../../models/user'; +import { pack } from '../../../../models/messaging-message'; +import read from '../../common/read-messaging-message'; + +/** + * Get messages + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [recipientId, recipientIdErr] = $.type(ID).get(params.userId); + if (recipientIdErr) return rej('invalid userId param'); + + // Fetch recipient + const recipient = await User.findOne({ + _id: recipientId + }, { + fields: { + _id: true + } + }); + + if (recipient === null) { + return rej('user not found'); + } + + // Get 'markAsRead' parameter + const [markAsRead = true, markAsReadErr] = $.bool.optional().get(params.markAsRead); + if (markAsReadErr) return rej('invalid markAsRead param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + const query = { + $or: [{ + userId: user._id, + recipientId: recipient._id + }, { + userId: recipient._id, + recipientId: user._id + }] + } as any; + + const sort = { + _id: -1 + }; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const messages = await Message + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(messages.map(async message => + await pack(message, user, { + populateRecipient: false + })))); + + if (messages.length === 0) { + return; + } + + // Mark as read all + if (markAsRead) { + read(user._id, recipient._id, messages); + } +}); diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts new file mode 100644 index 0000000000..db471839e7 --- /dev/null +++ b/src/server/api/endpoints/messaging/messages/create.ts @@ -0,0 +1,153 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import Message from '../../../../../models/messaging-message'; +import { isValidText } from '../../../../../models/messaging-message'; +import History from '../../../../../models/messaging-history'; +import User from '../../../../../models/user'; +import Mute from '../../../../../models/mute'; +import DriveFile from '../../../../../models/drive-file'; +import { pack } from '../../../../../models/messaging-message'; +import publishUserStream from '../../../../../publishers/stream'; +import { publishMessagingStream, publishMessagingIndexStream } from '../../../../../publishers/stream'; +import pushSw from '../../../../../publishers/push-sw'; +import config from '../../../../../config'; + +/** + * Create a message + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [recipientId, recipientIdErr] = $.type(ID).get(params.userId); + if (recipientIdErr) return rej('invalid userId param'); + + // Myself + if (recipientId.equals(user._id)) { + return rej('cannot send message to myself'); + } + + // Fetch recipient + const recipient = await User.findOne({ + _id: recipientId + }, { + fields: { + _id: true + } + }); + + if (recipient === null) { + return rej('user not found'); + } + + // Get 'text' parameter + const [text, textErr] = $.str.optional().pipe(isValidText).get(params.text); + if (textErr) return rej('invalid text'); + + // Get 'fileId' parameter + const [fileId, fileIdErr] = $.type(ID).optional().get(params.fileId); + if (fileIdErr) return rej('invalid fileId param'); + + let file = null; + if (fileId !== undefined) { + file = await DriveFile.findOne({ + _id: fileId, + 'metadata.userId': user._id + }); + + if (file === null) { + return rej('file not found'); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (text === undefined && file === null) { + return rej('text or file is required'); + } + + // メッセージを作成 + const message = await Message.insert({ + createdAt: new Date(), + fileId: file ? file._id : undefined, + recipientId: recipient._id, + text: text ? text : undefined, + userId: user._id, + isRead: false + }); + + // Serialize + const messageObj = await pack(message); + + // Reponse + res(messageObj); + + // 自分のストリーム + publishMessagingStream(message.userId, message.recipientId, 'message', messageObj); + publishMessagingIndexStream(message.userId, 'message', messageObj); + publishUserStream(message.userId, 'messaging_message', messageObj); + + // 相手のストリーム + publishMessagingStream(message.recipientId, message.userId, 'message', messageObj); + publishMessagingIndexStream(message.recipientId, 'message', messageObj); + publishUserStream(message.recipientId, 'messaging_message', messageObj); + + // 3秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する + setTimeout(async () => { + const freshMessage = await Message.findOne({ _id: message._id }, { isRead: true }); + if (!freshMessage.isRead) { + //#region ただしミュートされているなら発行しない + const mute = await Mute.find({ + muterId: recipient._id, + deletedAt: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.muteeId.toString()); + if (mutedUserIds.indexOf(user._id.toString()) != -1) { + return; + } + //#endregion + + publishUserStream(message.recipientId, 'unread_messaging_message', messageObj); + pushSw(message.recipientId, 'unread_messaging_message', messageObj); + } + }, 3000); + + // Register to search database + if (message.text && config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'messaging_message', + id: message._id.toString(), + body: { + text: message.text + } + }); + } + + // 履歴作成(自分) + History.update({ + userId: user._id, + partnerId: recipient._id + }, { + updatedAt: new Date(), + userId: user._id, + partnerId: recipient._id, + messageId: message._id + }, { + upsert: true + }); + + // 履歴作成(相手) + History.update({ + userId: recipient._id, + partnerId: user._id + }, { + updatedAt: new Date(), + userId: recipient._id, + partnerId: user._id, + messageId: message._id + }, { + upsert: true + }); +}); diff --git a/src/server/api/endpoints/messaging/unread.ts b/src/server/api/endpoints/messaging/unread.ts new file mode 100644 index 0000000000..1d83af501d --- /dev/null +++ b/src/server/api/endpoints/messaging/unread.ts @@ -0,0 +1,29 @@ +/** + * Module dependencies + */ +import Message from '../../../../models/messaging-message'; +import Mute from '../../../../models/mute'; + +/** + * Get count of unread messages + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const mute = await Mute.find({ + muterId: user._id, + deletedAt: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.muteeId); + + const count = await Message + .count({ + userId: { + $nin: mutedUserIds + }, + recipientId: user._id, + isRead: false + }); + + res({ + count: count + }); +}); diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts new file mode 100644 index 0000000000..0e9ecf47df --- /dev/null +++ b/src/server/api/endpoints/meta.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import * as os from 'os'; +import config from '../../../config'; +import Meta from '../../../models/meta'; + +const pkg = require('../../../../package.json'); +const client = require('../../../../built/client/meta.json'); + +/** + * @swagger + * /meta: + * note: + * summary: Show the misskey's information + * responses: + * 200: + * description: Success + * schema: + * type: object + * properties: + * maintainer: + * description: maintainer's name + * type: string + * commit: + * description: latest commit's hash + * type: string + * secure: + * description: whether the server supports secure protocols + * type: boolean + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Show core info + */ +module.exports = (params) => new Promise(async (res, rej) => { + const meta: any = (await Meta.findOne()) || {}; + + res({ + maintainer: config.maintainer, + + version: pkg.version, + clientVersion: client.version, + + secure: config.https != null, + machine: os.hostname(), + os: os.platform(), + node: process.version, + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length + }, + broadcasts: meta.broadcasts + }); +}); diff --git a/src/server/api/endpoints/mute/create.ts b/src/server/api/endpoints/mute/create.ts new file mode 100644 index 0000000000..534020c671 --- /dev/null +++ b/src/server/api/endpoints/mute/create.ts @@ -0,0 +1,56 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import User from '../../../../models/user'; +import Mute from '../../../../models/mute'; + +/** + * Mute a user + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const muter = user; + + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // 自分自身 + if (user._id.equals(userId)) { + return rej('mutee is yourself'); + } + + // Get mutee + const mutee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + profile: false + } + }); + + if (mutee === null) { + return rej('user not found'); + } + + // Check if already muting + const exist = await Mute.findOne({ + muterId: muter._id, + muteeId: mutee._id + }); + + if (exist !== null) { + return rej('already muting'); + } + + // Create mute + await Mute.insert({ + createdAt: new Date(), + muterId: muter._id, + muteeId: mutee._id, + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/mute/delete.ts b/src/server/api/endpoints/mute/delete.ts new file mode 100644 index 0000000000..949aff64ba --- /dev/null +++ b/src/server/api/endpoints/mute/delete.ts @@ -0,0 +1,54 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import User from '../../../../models/user'; +import Mute from '../../../../models/mute'; + +/** + * Unmute a user + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const muter = user; + + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Check if the mutee is yourself + if (user._id.equals(userId)) { + return rej('mutee is yourself'); + } + + // Get mutee + const mutee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + profile: false + } + }); + + if (mutee === null) { + return rej('user not found'); + } + + // Check not muting + const exist = await Mute.findOne({ + muterId: muter._id, + muteeId: mutee._id + }); + + if (exist === null) { + return rej('already not muting'); + } + + // Delete mute + await Mute.remove({ + _id: exist._id + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/mute/list.ts b/src/server/api/endpoints/mute/list.ts new file mode 100644 index 0000000000..cf89f7e959 --- /dev/null +++ b/src/server/api/endpoints/mute/list.ts @@ -0,0 +1,69 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/user'; +import { getFriendIds } from '../../common/get-friends'; + +/** + * Get muted users of a user + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'iknow' parameter + const [iknow = false, iknowErr] = $.bool.optional().get(params.iknow); + if (iknowErr) return rej('invalid iknow param'); + + // Get 'limit' parameter + const [limit = 30, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $.type(ID).optional().get(params.cursor); + if (cursorErr) return rej('invalid cursor param'); + + // Construct query + const query = { + muterId: me._id, + deletedAt: { $exists: false } + } as any; + + if (iknow) { + // Get my friends + const myFriends = await getFriendIds(me._id); + + query.muteeId = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get mutes + const mutes = await Mute + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = mutes.length === limit + 1; + if (inStock) { + mutes.pop(); + } + + // Serialize + const users = await Promise.all(mutes.map(async m => + await pack(m.muteeId, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? mutes[mutes.length - 1]._id : null, + }); +}); diff --git a/src/server/api/endpoints/my/apps.ts b/src/server/api/endpoints/my/apps.ts new file mode 100644 index 0000000000..086e0b8965 --- /dev/null +++ b/src/server/api/endpoints/my/apps.ts @@ -0,0 +1,36 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import App, { pack } from '../../../../models/app'; + +/** + * Get my apps + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); + + const query = { + userId: user._id + }; + + // Execute query + const apps = await App + .find(query, { + limit: limit, + skip: offset, + sort: { + _id: -1 + } + }); + + // Reply + res(await Promise.all(apps.map(async app => + await pack(app)))); +}); diff --git a/src/server/api/endpoints/notes.ts b/src/server/api/endpoints/notes.ts new file mode 100644 index 0000000000..4ce7613d70 --- /dev/null +++ b/src/server/api/endpoints/notes.ts @@ -0,0 +1,94 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../cafy-id'; +import Note, { pack } from '../../../models/note'; + +/** + * Get all notes + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'reply' parameter + const [reply, replyErr] = $.bool.optional().get(params.reply); + if (replyErr) return rej('invalid reply param'); + + // Get 'renote' parameter + const [renote, renoteErr] = $.bool.optional().get(params.renote); + if (renoteErr) return rej('invalid renote param'); + + // Get 'media' parameter + const [media, mediaErr] = $.bool.optional().get(params.media); + if (mediaErr) return rej('invalid media param'); + + // Get 'poll' parameter + const [poll, pollErr] = $.bool.optional().get(params.poll); + if (pollErr) return rej('invalid poll param'); + + // Get 'bot' parameter + //const [bot, botErr] = $.bool.optional().get(params.bot); + //if (botErr) return rej('invalid bot param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = {} as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + if (reply != undefined) { + query.replyId = reply ? { $exists: true, $ne: null } : null; + } + + if (renote != undefined) { + query.renoteId = renote ? { $exists: true, $ne: null } : null; + } + + if (media != undefined) { + query.mediaIds = media ? { $exists: true, $ne: null } : []; + } + + if (poll != undefined) { + query.poll = poll ? { $exists: true, $ne: null } : null; + } + + // TODO + //if (bot != undefined) { + // query.isBot = bot; + //} + + // Issue query + const notes = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(notes.map(note => pack(note)))); +}); diff --git a/src/server/api/endpoints/notes/context.ts b/src/server/api/endpoints/notes/context.ts new file mode 100644 index 0000000000..1cd27250e2 --- /dev/null +++ b/src/server/api/endpoints/notes/context.ts @@ -0,0 +1,63 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note, { pack } from '../../../../models/note'; + +/** + * Show a context of a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); + if (noteIdErr) return rej('invalid noteId param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); + + // Lookup note + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + const context = []; + let i = 0; + + async function get(id) { + i++; + const p = await Note.findOne({ _id: id }); + + if (i > offset) { + context.push(p); + } + + if (context.length == limit) { + return; + } + + if (p.replyId) { + await get(p.replyId); + } + } + + if (note.replyId) { + await get(note.replyId); + } + + // Serialize + res(await Promise.all(context.map(async note => + await pack(note, user)))); +}); diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts new file mode 100644 index 0000000000..429b6d370a --- /dev/null +++ b/src/server/api/endpoints/notes/create.ts @@ -0,0 +1,215 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note, { INote, isValidText, isValidCw, pack } from '../../../../models/note'; +import User, { ILocalUser } from '../../../../models/user'; +import Channel, { IChannel } from '../../../../models/channel'; +import DriveFile from '../../../../models/drive-file'; +import create from '../../../../services/note/create'; +import { IApp } from '../../../../models/app'; + +/** + * Create a note + */ +module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => { + // Get 'visibility' parameter + const [visibility = 'public', visibilityErr] = $.str.optional().or(['public', 'home', 'followers', 'specified', 'private']).get(params.visibility); + if (visibilityErr) return rej('invalid visibility'); + + // Get 'visibleUserIds' parameter + const [visibleUserIds, visibleUserIdsErr] = $.arr($.type(ID)).optional().unique().min(1).get(params.visibleUserIds); + if (visibleUserIdsErr) return rej('invalid visibleUserIds'); + + let visibleUsers = []; + if (visibleUserIds !== undefined) { + visibleUsers = await Promise.all(visibleUserIds.map(id => User.findOne({ + _id: id + }))); + } + + // Get 'text' parameter + const [text = null, textErr] = $.str.optional().nullable().pipe(isValidText).get(params.text); + if (textErr) return rej('invalid text'); + + // Get 'cw' parameter + const [cw, cwErr] = $.str.optional().nullable().pipe(isValidCw).get(params.cw); + if (cwErr) return rej('invalid cw'); + + // Get 'viaMobile' parameter + const [viaMobile = false, viaMobileErr] = $.bool.optional().get(params.viaMobile); + if (viaMobileErr) return rej('invalid viaMobile'); + + // Get 'tags' parameter + const [tags = [], tagsErr] = $.arr($.str.range(1, 32)).optional().unique().get(params.tags); + if (tagsErr) return rej('invalid tags'); + + // Get 'geo' parameter + const [geo, geoErr] = $.obj.optional().nullable().strict() + .have('coordinates', $.arr().length(2) + .item(0, $.num.range(-180, 180)) + .item(1, $.num.range(-90, 90))) + .have('altitude', $.num.nullable()) + .have('accuracy', $.num.nullable()) + .have('altitudeAccuracy', $.num.nullable()) + .have('heading', $.num.nullable().range(0, 360)) + .have('speed', $.num.nullable()) + .get(params.geo); + if (geoErr) return rej('invalid geo'); + + // Get 'mediaIds' parameter + const [mediaIds, mediaIdsErr] = $.arr($.type(ID)).optional().unique().range(1, 4).get(params.mediaIds); + if (mediaIdsErr) return rej('invalid mediaIds'); + + 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.userId': user._id + }); + + if (entity === null) { + return rej('file not found'); + } else { + files.push(entity); + } + } + } else { + files = null; + } + + // Get 'renoteId' parameter + const [renoteId, renoteIdErr] = $.type(ID).optional().get(params.renoteId); + if (renoteIdErr) return rej('invalid renoteId'); + + let renote: INote = null; + let isQuote = false; + if (renoteId !== undefined) { + // Fetch renote to note + renote = await Note.findOne({ + _id: renoteId + }); + + if (renote == null) { + return rej('renoteee is not found'); + } else if (renote.renoteId && !renote.text && !renote.mediaIds) { + return rej('cannot renote to renote'); + } + + isQuote = text != null || files != null; + } + + // Get 'replyId' parameter + const [replyId, replyIdErr] = $.type(ID).optional().get(params.replyId); + if (replyIdErr) return rej('invalid replyId'); + + let reply: INote = null; + if (replyId !== undefined) { + // Fetch reply + reply = await Note.findOne({ + _id: replyId + }); + + if (reply === null) { + return rej('in reply to note is not found'); + } + + // 返信対象が引用でないRenoteだったらエラー + if (reply.renoteId && !reply.text && !reply.mediaIds) { + return rej('cannot reply to renote'); + } + } + + // Get 'channelId' parameter + const [channelId, channelIdErr] = $.type(ID).optional().get(params.channelId); + if (channelIdErr) return rej('invalid channelId'); + + 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.channelId)) { + return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません'); + } + + // Renote対象の投稿がこのチャンネルじゃなかったらダメ + if (renote && !channelId.equals(renote.channelId)) { + return rej('チャンネル内部からチャンネル外部の投稿をRenoteすることはできません'); + } + + // 引用ではないRenoteはダメ + if (renote && !isQuote) { + return rej('チャンネル内部では引用ではないRenoteをすることはできません'); + } + } else { + // 返信対象の投稿がチャンネルへの投稿だったらダメ + if (reply && reply.channelId != null) { + return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません'); + } + + // Renote対象の投稿がチャンネルへの投稿だったらダメ + if (renote && renote.channelId != null) { + return rej('チャンネル外部からチャンネル内部の投稿をRenoteすることはできません'); + } + } + + // Get 'poll' parameter + const [poll, pollErr] = $.obj.optional().strict() + .have('choices', $.arr($.str) + .unique() + .range(2, 10) + .each(c => c.length > 0 && c.length < 50)) + .get(params.poll); + 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 + })); + } + + // テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー + if (text === undefined && files === null && renote === null && poll === undefined) { + return rej('text, mediaIds, renoteId or poll is required'); + } + + // 投稿を作成 + const note = await create(user, { + createdAt: new Date(), + media: files, + poll, + text, + reply, + renote, + cw, + tags, + app, + viaMobile, + visibility, + visibleUsers, + geo + }); + + const noteObj = await pack(note, user); + + // Reponse + res({ + createdNote: noteObj + }); +}); diff --git a/src/server/api/endpoints/notes/favorites/create.ts b/src/server/api/endpoints/notes/favorites/create.ts new file mode 100644 index 0000000000..6832b52f75 --- /dev/null +++ b/src/server/api/endpoints/notes/favorites/create.ts @@ -0,0 +1,44 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import Favorite from '../../../../../models/favorite'; +import Note from '../../../../../models/note'; + +/** + * Favorite a note + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); + if (noteIdErr) return rej('invalid noteId param'); + + // Get favoritee + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // if already favorited + const exist = await Favorite.findOne({ + noteId: note._id, + userId: user._id + }); + + if (exist !== null) { + return rej('already favorited'); + } + + // Create favorite + await Favorite.insert({ + createdAt: new Date(), + noteId: note._id, + userId: user._id + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/notes/favorites/delete.ts b/src/server/api/endpoints/notes/favorites/delete.ts new file mode 100644 index 0000000000..07112dae15 --- /dev/null +++ b/src/server/api/endpoints/notes/favorites/delete.ts @@ -0,0 +1,42 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import Favorite from '../../../../../models/favorite'; +import Note from '../../../../../models/note'; + +/** + * Unfavorite a note + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); + if (noteIdErr) return rej('invalid noteId param'); + + // Get favoritee + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // if already favorited + const exist = await Favorite.findOne({ + noteId: note._id, + userId: user._id + }); + + if (exist === null) { + return rej('already not favorited'); + } + + // Delete favorite + await Favorite.remove({ + _id: exist._id + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts new file mode 100644 index 0000000000..d22a1763de --- /dev/null +++ b/src/server/api/endpoints/notes/global-timeline.ts @@ -0,0 +1,91 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note from '../../../../models/note'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/note'; + +/** + * Get timeline of global + */ +module.exports = async (params, user, app) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) throw 'invalid limit param'; + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) throw 'invalid sinceId param'; + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) throw 'invalid untilId param'; + + // Get 'sinceDate' parameter + const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate); + if (sinceDateErr) throw 'invalid sinceDate param'; + + // Get 'untilDate' parameter + const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate); + if (untilDateErr) throw 'invalid untilDate param'; + + // Check if only one of sinceId, untilId, sinceDate, untilDate specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; + } + + // ミュートしているユーザーを取得 + const mutedUserIds = (await Mute.find({ + muterId: user._id + })).map(m => m.muteeId); + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + // mute + userId: { + $nin: mutedUserIds + }, + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $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.createdAt = { + $gt: new Date(sinceDate) + }; + } else if (untilDate) { + query.createdAt = { + $lt: new Date(untilDate) + }; + } + //#endregion + + // Issue query + const timeline = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + return await Promise.all(timeline.map(note => pack(note, user))); +}; diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts new file mode 100644 index 0000000000..e7ebe5d960 --- /dev/null +++ b/src/server/api/endpoints/notes/local-timeline.ts @@ -0,0 +1,94 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note from '../../../../models/note'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/note'; + +/** + * Get timeline of local + */ +module.exports = async (params, user, app) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) throw 'invalid limit param'; + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) throw 'invalid sinceId param'; + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) throw 'invalid untilId param'; + + // Get 'sinceDate' parameter + const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate); + if (sinceDateErr) throw 'invalid sinceDate param'; + + // Get 'untilDate' parameter + const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate); + if (untilDateErr) throw 'invalid untilDate param'; + + // Check if only one of sinceId, untilId, sinceDate, untilDate specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; + } + + // ミュートしているユーザーを取得 + const mutedUserIds = (await Mute.find({ + muterId: user._id + })).map(m => m.muteeId); + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + // mute + userId: { + $nin: mutedUserIds + }, + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + }, + + // local + '_user.host': null + } 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.createdAt = { + $gt: new Date(sinceDate) + }; + } else if (untilDate) { + query.createdAt = { + $lt: new Date(untilDate) + }; + } + //#endregion + + // Issue query + const timeline = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + return await Promise.all(timeline.map(note => pack(note, user))); +}; diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts new file mode 100644 index 0000000000..163a6b4866 --- /dev/null +++ b/src/server/api/endpoints/notes/mentions.ts @@ -0,0 +1,78 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note from '../../../../models/note'; +import { getFriendIds } from '../../common/get-friends'; +import { pack } from '../../../../models/note'; + +/** + * 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] = + $.bool.optional().get(params.following); + if (followingError) return rej('invalid following param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Construct query + const query = { + mentions: user._id + } as any; + + const sort = { + _id: -1 + }; + + if (following) { + const followingIds = await getFriendIds(user._id); + + query.userId = { + $in: followingIds + }; + } + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const mentions = await Note + .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/notes/polls/recommendation.ts b/src/server/api/endpoints/notes/polls/recommendation.ts new file mode 100644 index 0000000000..a272378d19 --- /dev/null +++ b/src/server/api/endpoints/notes/polls/recommendation.ts @@ -0,0 +1,55 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Vote from '../../../../../models/poll-vote'; +import Note, { pack } from '../../../../../models/note'; + +/** + * Get recommended polls + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); + + // Get votes + const votes = await Vote.find({ + userId: user._id + }, { + fields: { + _id: false, + noteId: true + } + }); + + const nin = votes && votes.length != 0 ? votes.map(v => v.noteId) : []; + + const notes = await Note + .find({ + _id: { + $nin: nin + }, + userId: { + $ne: user._id + }, + poll: { + $exists: true, + $ne: null + } + }, { + limit: limit, + skip: offset, + sort: { + _id: -1 + } + }); + + // Serialize + res(await Promise.all(notes.map(async note => + await pack(note, user, { detail: true })))); +}); diff --git a/src/server/api/endpoints/notes/polls/vote.ts b/src/server/api/endpoints/notes/polls/vote.ts new file mode 100644 index 0000000000..f8f4515308 --- /dev/null +++ b/src/server/api/endpoints/notes/polls/vote.ts @@ -0,0 +1,111 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import Vote from '../../../../../models/poll-vote'; +import Note from '../../../../../models/note'; +import Watching from '../../../../../models/note-watching'; +import watch from '../../../../../services/note/watch'; +import { publishNoteStream } from '../../../../../publishers/stream'; +import notify from '../../../../../publishers/notify'; + +/** + * Vote poll of a note + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); + if (noteIdErr) return rej('invalid noteId param'); + + // Get votee + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + if (note.poll == null) { + return rej('poll not found'); + } + + // Get 'choice' parameter + const [choice, choiceError] = + $.num + .pipe(c => note.poll.choices.some(x => x.id == c)) + .get(params.choice); + if (choiceError) return rej('invalid choice param'); + + // if already voted + const exist = await Vote.findOne({ + noteId: note._id, + userId: user._id + }); + + if (exist !== null) { + return rej('already voted'); + } + + // Create vote + await Vote.insert({ + createdAt: new Date(), + noteId: note._id, + userId: user._id, + choice: choice + }); + + // Send response + res(); + + const inc = {}; + inc[`poll.choices.${findWithAttr(note.poll.choices, 'id', choice)}.votes`] = 1; + + // Increment votes count + await Note.update({ _id: note._id }, { + $inc: inc + }); + + publishNoteStream(note._id, 'poll_voted'); + + // Notify + notify(note.userId, user._id, 'poll_vote', { + noteId: note._id, + choice: choice + }); + + // Fetch watchers + Watching + .find({ + noteId: note._id, + userId: { $ne: user._id }, + // 削除されたドキュメントは除く + deletedAt: { $exists: false } + }, { + fields: { + userId: true + } + }) + .then(watchers => { + watchers.forEach(watcher => { + notify(watcher.userId, user._id, 'poll_vote', { + noteId: note._id, + choice: choice + }); + }); + }); + + // この投稿をWatchする + if (user.settings.autoWatch !== false) { + watch(user._id, note); + } +}); + +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/notes/reactions.ts b/src/server/api/endpoints/notes/reactions.ts new file mode 100644 index 0000000000..4ad952a7a1 --- /dev/null +++ b/src/server/api/endpoints/notes/reactions.ts @@ -0,0 +1,57 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note from '../../../../models/note'; +import Reaction, { pack } from '../../../../models/note-reaction'; + +/** + * Show reactions of a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); + if (noteIdErr) return rej('invalid noteId param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort = 'desc', sortError] = $.str.optional().or('desc asc').get(params.sort); + if (sortError) return rej('invalid sort param'); + + // Lookup note + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // Issue query + const reactions = await Reaction + .find({ + noteId: note._id, + deletedAt: { $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/notes/reactions/create.ts b/src/server/api/endpoints/notes/reactions/create.ts new file mode 100644 index 0000000000..21757cb427 --- /dev/null +++ b/src/server/api/endpoints/notes/reactions/create.ts @@ -0,0 +1,37 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import Note from '../../../../../models/note'; +import create from '../../../../../services/note/reaction/create'; +import { validateReaction } from '../../../../../models/note-reaction'; + +/** + * React to a note + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); + if (noteIdErr) return rej('invalid noteId param'); + + // Get 'reaction' parameter + const [reaction, reactionErr] = $.str.pipe(validateReaction.ok).get(params.reaction); + if (reactionErr) return rej('invalid reaction param'); + + // Fetch reactee + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + try { + await create(user, note, reaction); + } catch (e) { + rej(e); + } + + res(); +}); diff --git a/src/server/api/endpoints/notes/reactions/delete.ts b/src/server/api/endpoints/notes/reactions/delete.ts new file mode 100644 index 0000000000..afb8629112 --- /dev/null +++ b/src/server/api/endpoints/notes/reactions/delete.ts @@ -0,0 +1,55 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import Reaction from '../../../../../models/note-reaction'; +import Note from '../../../../../models/note'; + +/** + * Unreact to a note + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); + if (noteIdErr) return rej('invalid noteId param'); + + // Fetch unreactee + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // if already unreacted + const exist = await Reaction.findOne({ + noteId: note._id, + userId: user._id, + deletedAt: { $exists: false } + }); + + if (exist === null) { + return rej('never reacted'); + } + + // Delete reaction + await Reaction.update({ + _id: exist._id + }, { + $set: { + deletedAt: new Date() + } + }); + + // Send response + res(); + + const dec = {}; + dec[`reactionCounts.${exist.reaction}`] = -1; + + // Decrement reactions count + Note.update({ _id: note._id }, { + $inc: dec + }); +}); diff --git a/src/server/api/endpoints/notes/replies.ts b/src/server/api/endpoints/notes/replies.ts new file mode 100644 index 0000000000..11d221d8f7 --- /dev/null +++ b/src/server/api/endpoints/notes/replies.ts @@ -0,0 +1,53 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note, { pack } from '../../../../models/note'; + +/** + * Show a replies of a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); + if (noteIdErr) return rej('invalid noteId param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort = 'desc', sortError] = $.str.optional().or('desc asc').get(params.sort); + if (sortError) return rej('invalid sort param'); + + // Lookup note + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // Issue query + const replies = await Note + .find({ replyId: note._id }, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }); + + // Serialize + res(await Promise.all(replies.map(async note => + await pack(note, user)))); +}); diff --git a/src/server/api/endpoints/notes/reposts.ts b/src/server/api/endpoints/notes/reposts.ts new file mode 100644 index 0000000000..3098211b61 --- /dev/null +++ b/src/server/api/endpoints/notes/reposts.ts @@ -0,0 +1,73 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note, { pack } from '../../../../models/note'; + +/** + * Show a renotes of a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); + if (noteIdErr) return rej('invalid noteId param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Lookup note + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = { + renoteId: note._id + } as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const renotes = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(renotes.map(async note => + await pack(note, user)))); +}); diff --git a/src/server/api/endpoints/notes/search.ts b/src/server/api/endpoints/notes/search.ts new file mode 100644 index 0000000000..9705dcfd6e --- /dev/null +++ b/src/server/api/endpoints/notes/search.ts @@ -0,0 +1,364 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +const escapeRegexp = require('escape-regexp'); +import Note from '../../../../models/note'; +import User from '../../../../models/user'; +import Mute from '../../../../models/mute'; +import { getFriendIds } from '../../common/get-friends'; +import { pack } from '../../../../models/note'; + +/** + * Search a note + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'text' parameter + const [text, textError] = $.str.optional().get(params.text); + if (textError) return rej('invalid text param'); + + // Get 'includeUserIds' parameter + const [includeUserIds = [], includeUserIdsErr] = $.arr($.type(ID)).optional().get(params.includeUserIds); + if (includeUserIdsErr) return rej('invalid includeUserIds param'); + + // Get 'excludeUserIds' parameter + const [excludeUserIds = [], excludeUserIdsErr] = $.arr($.type(ID)).optional().get(params.excludeUserIds); + if (excludeUserIdsErr) return rej('invalid excludeUserIds param'); + + // Get 'includeUserUsernames' parameter + const [includeUserUsernames = [], includeUserUsernamesErr] = $.arr($.str).optional().get(params.includeUserUsernames); + if (includeUserUsernamesErr) return rej('invalid includeUserUsernames param'); + + // Get 'excludeUserUsernames' parameter + const [excludeUserUsernames = [], excludeUserUsernamesErr] = $.arr($.str).optional().get(params.excludeUserUsernames); + if (excludeUserUsernamesErr) return rej('invalid excludeUserUsernames param'); + + // Get 'following' parameter + const [following = null, followingErr] = $.bool.optional().nullable().get(params.following); + if (followingErr) return rej('invalid following param'); + + // Get 'mute' parameter + const [mute = 'mute_all', muteErr] = $.str.optional().get(params.mute); + if (muteErr) return rej('invalid mute param'); + + // Get 'reply' parameter + const [reply = null, replyErr] = $.bool.optional().nullable().get(params.reply); + if (replyErr) return rej('invalid reply param'); + + // Get 'renote' parameter + const [renote = null, renoteErr] = $.bool.optional().nullable().get(params.renote); + if (renoteErr) return rej('invalid renote param'); + + // Get 'media' parameter + const [media = null, mediaErr] = $.bool.optional().nullable().get(params.media); + if (mediaErr) return rej('invalid media param'); + + // Get 'poll' parameter + const [poll = null, pollErr] = $.bool.optional().nullable().get(params.poll); + if (pollErr) return rej('invalid poll param'); + + // Get 'sinceDate' parameter + const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate); + if (sinceDateErr) throw 'invalid sinceDate param'; + + // Get 'untilDate' parameter + const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate); + if (untilDateErr) throw 'invalid untilDate param'; + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 30).get(params.limit); + 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({ + usernameLower: 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({ + usernameLower: 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, renote, media, poll, sinceDate, untilDate, offset, limit); +}); + +async function search( + res, rej, me, text, includeUserIds, excludeUserIds, following, + mute, reply, renote, 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({ + userId: { + $in: includeUserIds + } + }); + } else if (excludeUserIds && excludeUserIds.length != 0) { + push({ + userId: { + $nin: excludeUserIds + } + }); + } + + if (following != null && me != null) { + const ids = await getFriendIds(me._id, false); + push({ + userId: following ? { + $in: ids + } : { + $nin: ids.concat(me._id) + } + }); + } + + if (me != null) { + const mutes = await Mute.find({ + muterId: me._id, + deletedAt: { $exists: false } + }); + const mutedUserIds = mutes.map(m => m.muteeId); + + switch (mute) { + case 'mute_all': + push({ + userId: { + $nin: mutedUserIds + }, + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + } + }); + break; + case 'mute_related': + push({ + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + } + }); + break; + case 'mute_direct': + push({ + userId: { + $nin: mutedUserIds + } + }); + break; + case 'direct_only': + push({ + userId: { + $in: mutedUserIds + } + }); + break; + case 'related_only': + push({ + $or: [{ + '_reply.userId': { + $in: mutedUserIds + } + }, { + '_renote.userId': { + $in: mutedUserIds + } + }] + }); + break; + case 'all_only': + push({ + $or: [{ + userId: { + $in: mutedUserIds + } + }, { + '_reply.userId': { + $in: mutedUserIds + } + }, { + '_renote.userId': { + $in: mutedUserIds + } + }] + }); + break; + } + } + + if (reply != null) { + if (reply) { + push({ + replyId: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + replyId: { + $exists: false + } + }, { + replyId: null + }] + }); + } + } + + if (renote != null) { + if (renote) { + push({ + renoteId: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + renoteId: { + $exists: false + } + }, { + renoteId: null + }] + }); + } + } + + if (media != null) { + if (media) { + push({ + mediaIds: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + mediaIds: { + $exists: false + } + }, { + mediaIds: null + }] + }); + } + } + + if (poll != null) { + if (poll) { + push({ + poll: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + poll: { + $exists: false + } + }, { + poll: null + }] + }); + } + } + + if (sinceDate) { + push({ + createdAt: { + $gt: new Date(sinceDate) + } + }); + } + + if (untilDate) { + push({ + createdAt: { + $lt: new Date(untilDate) + } + }); + } + + if (q.$and.length == 0) { + q = {}; + } + + // Search notes + const notes = await Note + .find(q, { + sort: { + _id: -1 + }, + limit: max, + skip: offset + }); + + // Serialize + res(await Promise.all(notes.map(async note => + await pack(note, me)))); +} diff --git a/src/server/api/endpoints/notes/show.ts b/src/server/api/endpoints/notes/show.ts new file mode 100644 index 0000000000..78dc55a703 --- /dev/null +++ b/src/server/api/endpoints/notes/show.ts @@ -0,0 +1,32 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note, { pack } from '../../../../models/note'; + +/** + * Show a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); + if (noteIdErr) return rej('invalid noteId param'); + + // Get note + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // Serialize + res(await pack(note, user, { + detail: true + })); +}); diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts new file mode 100644 index 0000000000..78786d4a16 --- /dev/null +++ b/src/server/api/endpoints/notes/timeline.ts @@ -0,0 +1,195 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note from '../../../../models/note'; +import Mute from '../../../../models/mute'; +import ChannelWatching from '../../../../models/channel-watching'; +import { getFriends } from '../../common/get-friends'; +import { pack } from '../../../../models/note'; + +/** + * Get timeline of myself + */ +module.exports = async (params, user, app) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) throw 'invalid limit param'; + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) throw 'invalid sinceId param'; + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) throw 'invalid untilId param'; + + // Get 'sinceDate' parameter + const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate); + if (sinceDateErr) throw 'invalid sinceDate param'; + + // Get 'untilDate' parameter + const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate); + if (untilDateErr) throw 'invalid untilDate param'; + + // Check if only one of sinceId, untilId, sinceDate, untilDate specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; + } + + // Get 'includeMyRenotes' parameter + const [includeMyRenotes = true, includeMyRenotesErr] = $.bool.optional().get(params.includeMyRenotes); + if (includeMyRenotesErr) throw 'invalid includeMyRenotes param'; + + // Get 'includeRenotedMyNotes' parameter + const [includeRenotedMyNotes = true, includeRenotedMyNotesErr] = $.bool.optional().get(params.includeRenotedMyNotes); + if (includeRenotedMyNotesErr) throw 'invalid includeRenotedMyNotes param'; + + const [followings, watchingChannelIds, mutedUserIds] = await Promise.all([ + // フォローを取得 + // Fetch following + getFriends(user._id), + + // Watchしているチャンネルを取得 + ChannelWatching.find({ + userId: user._id, + // 削除されたドキュメントは除く + deletedAt: { $exists: false } + }).then(watches => watches.map(w => w.channelId)), + + // ミュートしているユーザーを取得 + Mute.find({ + muterId: user._id + }).then(ms => ms.map(m => m.muteeId)) + ]); + + //#region Construct query + const sort = { + _id: -1 + }; + + const followQuery = followings.map(f => f.stalk ? { + userId: f.id + } : { + userId: f.id, + + // ストーキングしてないならリプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) + $or: [{ + // リプライでない + replyId: null + }, { // または + // リプライだが返信先が投稿者自身の投稿 + $expr: { + $eq: ['$_reply.userId', '$userId'] + } + }, { // または + // リプライだが返信先が自分(フォロワー)の投稿 + '_reply.userId': user._id + }, { // または + // 自分(フォロワー)が送信したリプライ + userId: user._id + }] + }); + + const query = { + $and: [{ + $or: [{ + $and: [{ + // フォローしている人のタイムラインへの投稿 + $or: followQuery + }, { + // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る + $or: [{ + channelId: { + $exists: false + } + }, { + channelId: null + }] + }] + }, { + // Watchしているチャンネルへの投稿 + channelId: { + $in: watchingChannelIds + } + }], + // mute + userId: { + $nin: mutedUserIds + }, + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + }, + }] + } as any; + + // MongoDBではトップレベルで否定ができないため、De Morganの法則を利用してクエリします。 + // つまり、「『自分の投稿かつRenote』ではない」を「『自分の投稿ではない』または『Renoteではない』」と表現します。 + // for details: https://en.wikipedia.org/wiki/De_Morgan%27s_laws + + if (includeMyRenotes === false) { + query.$and.push({ + $or: [{ + userId: { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (includeRenotedMyNotes === false) { + query.$and.push({ + $or: [{ + '_renote.userId': { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } else if (sinceDate) { + sort._id = 1; + query.createdAt = { + $gt: new Date(sinceDate) + }; + } else if (untilDate) { + query.createdAt = { + $lt: new Date(untilDate) + }; + } + //#endregion + + // Issue query + const timeline = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + return await Promise.all(timeline.map(note => pack(note, user))); +}; diff --git a/src/server/api/endpoints/notes/trend.ts b/src/server/api/endpoints/notes/trend.ts new file mode 100644 index 0000000000..9cb3debe63 --- /dev/null +++ b/src/server/api/endpoints/notes/trend.ts @@ -0,0 +1,79 @@ +/** + * Module dependencies + */ +const ms = require('ms'); +import $ from 'cafy'; +import Note, { pack } from '../../../../models/note'; + +/** + * Get trend notes + * + * @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] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); + + // Get 'reply' parameter + const [reply, replyErr] = $.bool.optional().get(params.reply); + if (replyErr) return rej('invalid reply param'); + + // Get 'renote' parameter + const [renote, renoteErr] = $.bool.optional().get(params.renote); + if (renoteErr) return rej('invalid renote param'); + + // Get 'media' parameter + const [media, mediaErr] = $.bool.optional().get(params.media); + if (mediaErr) return rej('invalid media param'); + + // Get 'poll' parameter + const [poll, pollErr] = $.bool.optional().get(params.poll); + if (pollErr) return rej('invalid poll param'); + + const query = { + createdAt: { + $gte: new Date(Date.now() - ms('1days')) + }, + renoteCount: { + $gt: 0 + } + } as any; + + if (reply != undefined) { + query.replyId = reply ? { $exists: true, $ne: null } : null; + } + + if (renote != undefined) { + query.renoteId = renote ? { $exists: true, $ne: null } : null; + } + + if (media != undefined) { + query.mediaIds = media ? { $exists: true, $ne: null } : null; + } + + if (poll != undefined) { + query.poll = poll ? { $exists: true, $ne: null } : null; + } + + // Issue query + const notes = await Note + .find(query, { + limit: limit, + skip: offset, + sort: { + renoteCount: -1, + _id: -1 + } + }); + + // Serialize + res(await Promise.all(notes.map(async note => + await pack(note, user, { detail: true })))); +}); diff --git a/src/server/api/endpoints/notes/user-list-timeline.ts b/src/server/api/endpoints/notes/user-list-timeline.ts new file mode 100644 index 0000000000..9f8397d679 --- /dev/null +++ b/src/server/api/endpoints/notes/user-list-timeline.ts @@ -0,0 +1,179 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note from '../../../../models/note'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/note'; +import UserList from '../../../../models/user-list'; + +/** + * Get timeline of a user list + */ +module.exports = async (params, user, app) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) throw 'invalid limit param'; + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) throw 'invalid sinceId param'; + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) throw 'invalid untilId param'; + + // Get 'sinceDate' parameter + const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate); + if (sinceDateErr) throw 'invalid sinceDate param'; + + // Get 'untilDate' parameter + const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate); + if (untilDateErr) throw 'invalid untilDate param'; + + // Check if only one of sinceId, untilId, sinceDate, untilDate specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; + } + + // Get 'includeMyRenotes' parameter + const [includeMyRenotes = true, includeMyRenotesErr] = $.bool.optional().get(params.includeMyRenotes); + if (includeMyRenotesErr) throw 'invalid includeMyRenotes param'; + + // Get 'includeRenotedMyNotes' parameter + const [includeRenotedMyNotes = true, includeRenotedMyNotesErr] = $.bool.optional().get(params.includeRenotedMyNotes); + if (includeRenotedMyNotesErr) throw 'invalid includeRenotedMyNotes param'; + + // Get 'listId' parameter + const [listId, listIdErr] = $.type(ID).get(params.listId); + if (listIdErr) throw 'invalid listId param'; + + const [list, mutedUserIds] = await Promise.all([ + // リストを取得 + // Fetch the list + UserList.findOne({ + _id: listId, + userId: user._id + }), + + // ミュートしているユーザーを取得 + Mute.find({ + muterId: user._id + }).then(ms => ms.map(m => m.muteeId)) + ]); + + if (list.userIds.length == 0) { + return []; + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const listQuery = list.userIds.map(u => ({ + userId: u, + + // リプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) + $or: [{ + // リプライでない + replyId: null + }, { // または + // リプライだが返信先が投稿者自身の投稿 + $expr: { + $eq: ['$_reply.userId', '$userId'] + } + }, { // または + // リプライだが返信先が自分(フォロワー)の投稿 + '_reply.userId': user._id + }, { // または + // 自分(フォロワー)が送信したリプライ + userId: user._id + }] + })); + + const query = { + $and: [{ + // リストに入っている人のタイムラインへの投稿 + $or: listQuery, + + // mute + userId: { + $nin: mutedUserIds + }, + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + }, + }] + } as any; + + // MongoDBではトップレベルで否定ができないため、De Morganの法則を利用してクエリします。 + // つまり、「『自分の投稿かつRenote』ではない」を「『自分の投稿ではない』または『Renoteではない』」と表現します。 + // for details: https://en.wikipedia.org/wiki/De_Morgan%27s_laws + + if (includeMyRenotes === false) { + query.$and.push({ + $or: [{ + userId: { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (includeRenotedMyNotes === false) { + query.$and.push({ + $or: [{ + '_renote.userId': { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } else if (sinceDate) { + sort._id = 1; + query.createdAt = { + $gt: new Date(sinceDate) + }; + } else if (untilDate) { + query.createdAt = { + $lt: new Date(untilDate) + }; + } + //#endregion + + // Issue query + const timeline = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + return await Promise.all(timeline.map(note => pack(note, user))); +}; diff --git a/src/server/api/endpoints/notifications/get_unread_count.ts b/src/server/api/endpoints/notifications/get_unread_count.ts new file mode 100644 index 0000000000..600a80d194 --- /dev/null +++ b/src/server/api/endpoints/notifications/get_unread_count.ts @@ -0,0 +1,29 @@ +/** + * Module dependencies + */ +import Notification from '../../../../models/notification'; +import Mute from '../../../../models/mute'; + +/** + * Get count of unread notifications + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const mute = await Mute.find({ + muterId: user._id, + deletedAt: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.muteeId); + + const count = await Notification + .count({ + notifieeId: user._id, + notifierId: { + $nin: mutedUserIds + }, + isRead: false + }); + + res({ + count: count + }); +}); diff --git a/src/server/api/endpoints/notifications/mark_as_read_all.ts b/src/server/api/endpoints/notifications/mark_as_read_all.ts new file mode 100644 index 0000000000..dce3cb4663 --- /dev/null +++ b/src/server/api/endpoints/notifications/mark_as_read_all.ts @@ -0,0 +1,28 @@ +/** + * Module dependencies + */ +import Notification from '../../../../models/notification'; +import event from '../../../../publishers/stream'; + +/** + * Mark as read all notifications + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Update documents + await Notification.update({ + notifieeId: user._id, + isRead: false + }, { + $set: { + isRead: true + } + }, { + multi: true + }); + + // Response + res(); + + // 全ての通知を読みましたよというイベントを発行 + event(user._id, 'read_all_notifications'); +}); diff --git a/src/server/api/endpoints/othello/games.ts b/src/server/api/endpoints/othello/games.ts new file mode 100644 index 0000000000..2320a34b04 --- /dev/null +++ b/src/server/api/endpoints/othello/games.ts @@ -0,0 +1,62 @@ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import OthelloGame, { pack } from '../../../../models/othello-game'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'my' parameter + const [my = false, myErr] = $.bool.optional().get(params.my); + if (myErr) return rej('invalid my param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + const q: any = my ? { + isStarted: true, + $or: [{ + user1Id: user._id + }, { + user2Id: user._id + }] + } : { + isStarted: true + }; + + const sort = { + _id: -1 + }; + + if (sinceId) { + sort._id = 1; + q._id = { + $gt: sinceId + }; + } else if (untilId) { + q._id = { + $lt: untilId + }; + } + + // Fetch games + const games = await OthelloGame.find(q, { + sort, + limit + }); + + // Reponse + res(Promise.all(games.map(async (g) => await pack(g, user, { + detail: false + })))); +}); diff --git a/src/server/api/endpoints/othello/games/show.ts b/src/server/api/endpoints/othello/games/show.ts new file mode 100644 index 0000000000..6b2f5ce137 --- /dev/null +++ b/src/server/api/endpoints/othello/games/show.ts @@ -0,0 +1,32 @@ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import OthelloGame, { pack } from '../../../../../models/othello-game'; +import Othello from '../../../../../othello/core'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'gameId' parameter + const [gameId, gameIdErr] = $.type(ID).get(params.gameId); + if (gameIdErr) return rej('invalid gameId param'); + + const game = await OthelloGame.findOne({ _id: gameId }); + + if (game == null) { + return rej('game not found'); + } + + const o = new Othello(game.settings.map, { + isLlotheo: game.settings.isLlotheo, + canPutEverywhere: game.settings.canPutEverywhere, + loopedBoard: game.settings.loopedBoard + }); + + game.logs.forEach(log => { + o.put(log.color, log.pos); + }); + + const packed = await pack(game, user); + + res(Object.assign({ + board: o.board, + turn: o.turn + }, packed)); +}); diff --git a/src/server/api/endpoints/othello/invitations.ts b/src/server/api/endpoints/othello/invitations.ts new file mode 100644 index 0000000000..4761537614 --- /dev/null +++ b/src/server/api/endpoints/othello/invitations.ts @@ -0,0 +1,15 @@ +import Matching, { pack as packMatching } from '../../../../models/othello-matching'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + // Find session + const invitations = await Matching.find({ + childId: user._id + }, { + sort: { + _id: -1 + } + }); + + // Reponse + res(Promise.all(invitations.map(async (i) => await packMatching(i, user)))); +}); diff --git a/src/server/api/endpoints/othello/match.ts b/src/server/api/endpoints/othello/match.ts new file mode 100644 index 0000000000..e70e579755 --- /dev/null +++ b/src/server/api/endpoints/othello/match.ts @@ -0,0 +1,95 @@ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Matching, { pack as packMatching } from '../../../../models/othello-matching'; +import OthelloGame, { pack as packGame } from '../../../../models/othello-game'; +import User from '../../../../models/user'; +import publishUserStream, { publishOthelloStream } from '../../../../publishers/stream'; +import { eighteight } from '../../../../othello/maps'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [childId, childIdErr] = $.type(ID).get(params.userId); + if (childIdErr) return rej('invalid userId param'); + + // Myself + if (childId.equals(user._id)) { + return rej('invalid userId param'); + } + + // Find session + const exist = await Matching.findOne({ + parentId: childId, + childId: user._id + }); + + if (exist) { + // Destroy session + Matching.remove({ + _id: exist._id + }); + + // Create game + const game = await OthelloGame.insert({ + createdAt: new Date(), + user1Id: exist.parentId, + user2Id: user._id, + user1Accepted: false, + user2Accepted: false, + isStarted: false, + isEnded: false, + logs: [], + settings: { + map: eighteight.data, + bw: 'random', + isLlotheo: false + } + }); + + // Reponse + res(await packGame(game, user)); + + publishOthelloStream(exist.parentId, 'matched', await packGame(game, exist.parentId)); + + const other = await Matching.count({ + childId: user._id + }); + + if (other == 0) { + publishUserStream(user._id, 'othello_no_invites'); + } + } else { + // Fetch child + const child = await User.findOne({ + _id: childId + }, { + fields: { + _id: true + } + }); + + if (child === null) { + return rej('user not found'); + } + + // 以前のセッションはすべて削除しておく + await Matching.remove({ + parentId: user._id + }); + + // セッションを作成 + const matching = await Matching.insert({ + createdAt: new Date(), + parentId: user._id, + childId: child._id + }); + + // Reponse + res(); + + const packed = await packMatching(matching, child); + + // 招待 + publishOthelloStream(child._id, 'invited', packed); + + publishUserStream(child._id, 'othello_invited', packed); + } +}); diff --git a/src/server/api/endpoints/othello/match/cancel.ts b/src/server/api/endpoints/othello/match/cancel.ts new file mode 100644 index 0000000000..562e691061 --- /dev/null +++ b/src/server/api/endpoints/othello/match/cancel.ts @@ -0,0 +1,9 @@ +import Matching from '../../../../../models/othello-matching'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + await Matching.remove({ + parentId: user._id + }); + + res(); +}); diff --git a/src/server/api/endpoints/stats.ts b/src/server/api/endpoints/stats.ts new file mode 100644 index 0000000000..52e5195484 --- /dev/null +++ b/src/server/api/endpoints/stats.ts @@ -0,0 +1,48 @@ +/** + * Module dependencies + */ +import Note from '../../../models/note'; +import User from '../../../models/user'; + +/** + * @swagger + * /stats: + * note: + * summary: Show the misskey's statistics + * responses: + * 200: + * description: Success + * schema: + * type: object + * properties: + * notesCount: + * description: count of all notes of misskey + * type: number + * usersCount: + * description: count of all users of misskey + * type: number + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Show the misskey's statistics + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = params => new Promise(async (res, rej) => { + const notesCount = await Note + .count(); + + const usersCount = await User + .count(); + + res({ + notesCount: notesCount, + usersCount: usersCount + }); +}); diff --git a/src/server/api/endpoints/sw/register.ts b/src/server/api/endpoints/sw/register.ts new file mode 100644 index 0000000000..b22a8b08ef --- /dev/null +++ b/src/server/api/endpoints/sw/register.ts @@ -0,0 +1,44 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Subscription from '../../../../models/sw-subscription'; + +/** + * subscribe service worker + */ +module.exports = async (params, user, app) => new Promise(async (res, rej) => { + // Get 'endpoint' parameter + const [endpoint, endpointErr] = $.str.get(params.endpoint); + if (endpointErr) return rej('invalid endpoint param'); + + // Get 'auth' parameter + const [auth, authErr] = $.str.get(params.auth); + if (authErr) return rej('invalid auth param'); + + // Get 'publickey' parameter + const [publickey, publickeyErr] = $.str.get(params.publickey); + if (publickeyErr) return rej('invalid publickey param'); + + // if already subscribed + const exist = await Subscription.findOne({ + userId: user._id, + endpoint: endpoint, + auth: auth, + publickey: publickey, + deletedAt: { $exists: false } + }); + + if (exist !== null) { + return res(); + } + + await Subscription.insert({ + userId: user._id, + endpoint: endpoint, + auth: auth, + publickey: publickey + }); + + res(); +}); diff --git a/src/server/api/endpoints/username/available.ts b/src/server/api/endpoints/username/available.ts new file mode 100644 index 0000000000..b11bec4e58 --- /dev/null +++ b/src/server/api/endpoints/username/available.ts @@ -0,0 +1,32 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import { validateUsername } from '../../../../models/user'; + +/** + * Check available username + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = async (params) => new Promise(async (res, rej) => { + // Get 'username' parameter + const [username, usernameError] = $.str.pipe(validateUsername).get(params.username); + if (usernameError) return rej('invalid username param'); + + // Get exist + const exist = await User + .count({ + host: null, + usernameLower: username.toLowerCase() + }, { + limit: 1 + }); + + // Reply + res({ + available: exist === 0 + }); +}); diff --git a/src/server/api/endpoints/users.ts b/src/server/api/endpoints/users.ts new file mode 100644 index 0000000000..eb581cb7e6 --- /dev/null +++ b/src/server/api/endpoints/users.ts @@ -0,0 +1,54 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { pack } from '../../../models/user'; + +/** + * Lists all users + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort, sortError] = $.str.optional().or('+follower|-follower').get(params.sort); + if (sortError) return rej('invalid sort param'); + + // Construct query + let _sort; + if (sort) { + if (sort == '+follower') { + _sort = { + followersCount: -1 + }; + } else if (sort == '-follower') { + _sort = { + followersCount: 1 + }; + } + } else { + _sort = { + _id: -1 + }; + } + + // Issue query + const users = await User + .find({ + host: null + }, { + limit: limit, + sort: _sort, + skip: offset + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me)))); +}); diff --git a/src/server/api/endpoints/users/followers.ts b/src/server/api/endpoints/users/followers.ts new file mode 100644 index 0000000000..810cd7341b --- /dev/null +++ b/src/server/api/endpoints/users/followers.ts @@ -0,0 +1,87 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import User from '../../../../models/user'; +import Following from '../../../../models/following'; +import { pack } from '../../../../models/user'; +import { getFriendIds } from '../../common/get-friends'; + +/** + * Get followers of a user + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Get 'iknow' parameter + const [iknow = false, iknowErr] = $.bool.optional().get(params.iknow); + if (iknowErr) return rej('invalid iknow param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $.type(ID).optional().get(params.cursor); + if (cursorErr) return rej('invalid cursor param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const query = { + followeeId: user._id + } as any; + + // ログインしていてかつ iknow フラグがあるとき + if (me && iknow) { + // Get my friends + const myFriends = await getFriendIds(me._id); + + query.followerId = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get followers + const following = await Following + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = following.length === limit + 1; + if (inStock) { + following.pop(); + } + + // Serialize + const users = await Promise.all(following.map(async f => + await pack(f.followerId, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? following[following.length - 1]._id : null, + }); +}); diff --git a/src/server/api/endpoints/users/following.ts b/src/server/api/endpoints/users/following.ts new file mode 100644 index 0000000000..3373b9d632 --- /dev/null +++ b/src/server/api/endpoints/users/following.ts @@ -0,0 +1,91 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import User from '../../../../models/user'; +import Following from '../../../../models/following'; +import { pack } from '../../../../models/user'; +import { getFriendIds } from '../../common/get-friends'; + +/** + * Get following users of a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Get 'iknow' parameter + const [iknow = false, iknowErr] = $.bool.optional().get(params.iknow); + if (iknowErr) return rej('invalid iknow param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $.type(ID).optional().get(params.cursor); + if (cursorErr) return rej('invalid cursor param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const query = { + followerId: user._id + } as any; + + // ログインしていてかつ iknow フラグがあるとき + if (me && iknow) { + // Get my friends + const myFriends = await getFriendIds(me._id); + + query.followeeId = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get followers + const following = await Following + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = following.length === limit + 1; + if (inStock) { + following.pop(); + } + + // Serialize + const users = await Promise.all(following.map(async f => + await pack(f.followeeId, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? following[following.length - 1]._id : null, + }); +}); diff --git a/src/server/api/endpoints/users/get_frequently_replied_users.ts b/src/server/api/endpoints/users/get_frequently_replied_users.ts new file mode 100644 index 0000000000..64d737a06b --- /dev/null +++ b/src/server/api/endpoints/users/get_frequently_replied_users.ts @@ -0,0 +1,99 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note from '../../../../models/note'; +import User, { pack } from '../../../../models/user'; + +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + // Fetch recent notes + const recentNotes = await Note.find({ + userId: user._id, + replyId: { + $exists: true, + $ne: null + } + }, { + sort: { + _id: -1 + }, + limit: 1000, + fields: { + _id: false, + replyId: true + } + }); + + // 投稿が少なかったら中断 + if (recentNotes.length === 0) { + return res([]); + } + + const replyTargetNotes = await Note.find({ + _id: { + $in: recentNotes.map(p => p.replyId) + }, + userId: { + $ne: user._id + } + }, { + fields: { + _id: false, + userId: true + } + }); + + const repliedUsers = {}; + + // Extract replies from recent notes + replyTargetNotes.forEach(note => { + const userId = note.userId.toString(); + if (repliedUsers[userId]) { + repliedUsers[userId]++; + } else { + repliedUsers[userId] = 1; + } + }); + + // Calc peak + let peak = 0; + Object.keys(repliedUsers).forEach(user => { + if (repliedUsers[user] > peak) peak = repliedUsers[user]; + }); + + // Sort replies by frequency + const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); + + // Extract top replied users + const topRepliedUsers = repliedUsersSorted.slice(0, limit); + + // Make replies object (includes weights) + const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ + user: await pack(user, me, { detail: true }), + weight: repliedUsers[user] / peak + }))); + + // Response + res(repliesObj); +}); diff --git a/src/server/api/endpoints/users/lists/create.ts b/src/server/api/endpoints/users/lists/create.ts new file mode 100644 index 0000000000..100a78b872 --- /dev/null +++ b/src/server/api/endpoints/users/lists/create.ts @@ -0,0 +1,25 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import UserList, { pack } from '../../../../../models/user-list'; + +/** + * Create a user list + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'title' parameter + const [title, titleErr] = $.str.range(1, 100).get(params.title); + if (titleErr) return rej('invalid title param'); + + // insert + const userList = await UserList.insert({ + createdAt: new Date(), + userId: user._id, + title: title, + userIds: [] + }); + + // Response + res(await pack(userList)); +}); diff --git a/src/server/api/endpoints/users/lists/list.ts b/src/server/api/endpoints/users/lists/list.ts new file mode 100644 index 0000000000..d19339a1f5 --- /dev/null +++ b/src/server/api/endpoints/users/lists/list.ts @@ -0,0 +1,13 @@ +import UserList, { pack } from '../../../../../models/user-list'; + +/** + * Add a user to a user list + */ +module.exports = async (params, me) => new Promise(async (res, rej) => { + // Fetch lists + const userLists = await UserList.find({ + userId: me._id, + }); + + res(await Promise.all(userLists.map(x => pack(x)))); +}); diff --git a/src/server/api/endpoints/users/lists/push.ts b/src/server/api/endpoints/users/lists/push.ts new file mode 100644 index 0000000000..da5a9a134c --- /dev/null +++ b/src/server/api/endpoints/users/lists/push.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import UserList from '../../../../../models/user-list'; +import User, { pack as packUser, isRemoteUser, getGhost } from '../../../../../models/user'; +import { publishUserListStream } from '../../../../../publishers/stream'; +import ap from '../../../../../remote/activitypub/renderer'; +import renderFollow from '../../../../../remote/activitypub/renderer/follow'; +import { deliver } from '../../../../../queue'; + +/** + * Add a user to a user list + */ +module.exports = async (params, me) => new Promise(async (res, rej) => { + // Get 'listId' parameter + const [listId, listIdErr] = $.type(ID).get(params.listId); + if (listIdErr) return rej('invalid listId param'); + + // Fetch the list + const userList = await UserList.findOne({ + _id: listId, + userId: me._id, + }); + + if (userList == null) { + return rej('list not found'); + } + + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Fetch the user + const user = await User.findOne({ + _id: userId + }); + + if (user == null) { + return rej('user not found'); + } + + if (userList.userIds.map(id => id.toHexString()).includes(user._id.toHexString())) { + return rej('the user already added'); + } + + // Push the user + await UserList.update({ _id: userList._id }, { + $push: { + userIds: user._id + } + }); + + res(); + + publishUserListStream(userList._id, 'userAdded', await packUser(user)); + + // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする + if (isRemoteUser(user)) { + const ghost = await getGhost(); + const content = ap(renderFollow(ghost, user)); + deliver(ghost, content, user.inbox); + } +}); diff --git a/src/server/api/endpoints/users/lists/show.ts b/src/server/api/endpoints/users/lists/show.ts new file mode 100644 index 0000000000..16cb3382fd --- /dev/null +++ b/src/server/api/endpoints/users/lists/show.ts @@ -0,0 +1,23 @@ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import UserList, { pack } from '../../../../../models/user-list'; + +/** + * Show a user list + */ +module.exports = async (params, me) => new Promise(async (res, rej) => { + // Get 'listId' parameter + const [listId, listIdErr] = $.type(ID).get(params.listId); + if (listIdErr) return rej('invalid listId param'); + + // Fetch the list + const userList = await UserList.findOne({ + _id: listId, + userId: me._id, + }); + + if (userList == null) { + return rej('list not found'); + } + + res(await pack(userList)); +}); diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts new file mode 100644 index 0000000000..061c363d0f --- /dev/null +++ b/src/server/api/endpoints/users/notes.ts @@ -0,0 +1,133 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import getHostLower from '../../common/get-host-lower'; +import Note, { pack } from '../../../../models/note'; +import User from '../../../../models/user'; + +/** + * Get notes of a user + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).optional().get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Get 'username' parameter + const [username, usernameErr] = $.str.optional().get(params.username); + if (usernameErr) return rej('invalid username param'); + + if (userId === undefined && username === undefined) { + return rej('userId or pair of username and host is required'); + } + + // Get 'host' parameter + const [host, hostErr] = $.str.optional().get(params.host); + if (hostErr) return rej('invalid host param'); + + if (userId === undefined && host === undefined) { + return rej('userId or pair of username and host is required'); + } + + // Get 'includeReplies' parameter + const [includeReplies = true, includeRepliesErr] = $.bool.optional().get(params.includeReplies); + if (includeRepliesErr) return rej('invalid includeReplies param'); + + // Get 'withMedia' parameter + const [withMedia = false, withMediaErr] = $.bool.optional().get(params.withMedia); + if (withMediaErr) return rej('invalid withMedia param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Get 'sinceDate' parameter + const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate); + if (sinceDateErr) throw 'invalid sinceDate param'; + + // Get 'untilDate' parameter + const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate); + if (untilDateErr) throw 'invalid untilDate param'; + + // Check if only one of sinceId, untilId, sinceDate, untilDate specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; + } + + const q = userId !== undefined + ? { _id: userId } + : { usernameLower: username.toLowerCase(), host: getHostLower(host) } ; + + // Lookup user + const user = await User.findOne(q, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + userId: user._id + } 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.createdAt = { + $gt: new Date(sinceDate) + }; + } else if (untilDate) { + query.createdAt = { + $lt: new Date(untilDate) + }; + } + + if (!includeReplies) { + query.replyId = null; + } + + if (withMedia) { + query.mediaIds = { + $exists: true, + $ne: [] + }; + } + //#endregion + + // Issue query + const notes = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(notes.map(async (note) => + await pack(note, me) + ))); +}); diff --git a/src/server/api/endpoints/users/recommendation.ts b/src/server/api/endpoints/users/recommendation.ts new file mode 100644 index 0000000000..620ae17ca2 --- /dev/null +++ b/src/server/api/endpoints/users/recommendation.ts @@ -0,0 +1,57 @@ +/** + * Module dependencies + */ +const ms = require('ms'); +import $ from 'cafy'; +import User, { pack } from '../../../../models/user'; +import { getFriendIds } from '../../common/get-friends'; +import Mute from '../../../../models/mute'; + +/** + * Get recommended users + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); + + // ID list of the user itself and other users who the user follows + const followingIds = await getFriendIds(me._id); + + // ミュートしているユーザーを取得 + const mutedUserIds = (await Mute.find({ + muterId: me._id + })).map(m => m.muteeId); + + const users = await User + .find({ + _id: { + $nin: followingIds.concat(mutedUserIds) + }, + $or: [{ + lastUsedAt: { + $gte: new Date(Date.now() - ms('7days')) + } + }, { + host: null + }] + }, { + limit: limit, + skip: offset, + sort: { + followersCount: -1 + } + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); +}); diff --git a/src/server/api/endpoints/users/search.ts b/src/server/api/endpoints/users/search.ts new file mode 100644 index 0000000000..cfbdc337bf --- /dev/null +++ b/src/server/api/endpoints/users/search.ts @@ -0,0 +1,98 @@ +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import $ from 'cafy'; +import User, { pack } from '../../../../models/user'; +import config from '../../../../config'; +const escapeRegexp = require('escape-regexp'); + +/** + * Search a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'query' parameter + const [query, queryError] = $.str.pipe(x => x != '').get(params.query); + if (queryError) return rej('invalid query param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); + + // Get 'max' parameter + const [max = 10, maxErr] = $.num.optional().range(1, 30).get(params.max); + if (maxErr) return rej('invalid max param'); + + // If Elasticsearch is available, search by $ + // If not, search by MongoDB + (config.elasticsearch.enable ? byElasticsearch : byNative) + (res, rej, me, query, offset, max); +}); + +// Search by MongoDB +async function byNative(res, rej, me, query, offset, max) { + const escapedQuery = escapeRegexp(query); + + // Search users + const users = await User + .find({ + $or: [{ + usernameLower: new RegExp(escapedQuery.replace('@', '').toLowerCase()) + }, { + name: new RegExp(escapedQuery) + }] + }, { + limit: max + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); +} + +// Search by Elasticsearch +async function byElasticsearch(res, rej, me, query, offset, max) { + const es = require('../../db/elasticsearch'); + + es.search({ + index: 'misskey', + type: 'user', + body: { + size: max, + from: offset, + query: { + simple_query_string: { + fields: ['username', 'name', 'bio'], + query: query, + default_operator: 'and' + } + } + } + }, async (error, response) => { + if (error) { + console.error(error); + return res(500); + } + + if (response.hits.total === 0) { + return res([]); + } + + const hits = response.hits.hits.map(hit => new mongo.ObjectID(hit._id)); + + const users = await User + .find({ + _id: { + $in: hits + } + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); + }); +} diff --git a/src/server/api/endpoints/users/search_by_username.ts b/src/server/api/endpoints/users/search_by_username.ts new file mode 100644 index 0000000000..5927d00faf --- /dev/null +++ b/src/server/api/endpoints/users/search_by_username.ts @@ -0,0 +1,34 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { pack } from '../../../../models/user'; + +/** + * Search a user by username + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'query' parameter + const [query, queryError] = $.str.get(params.query); + if (queryError) return rej('invalid query param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + const users = await User + .find({ + usernameLower: new RegExp(query.toLowerCase()) + }, { + limit: limit, + skip: offset + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); +}); diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts new file mode 100644 index 0000000000..b8c6ff25c4 --- /dev/null +++ b/src/server/api/endpoints/users/show.ts @@ -0,0 +1,68 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import User, { pack } from '../../../../models/user'; +import resolveRemoteUser from '../../../../remote/resolve-user'; + +const cursorOption = { fields: { data: false } }; + +/** + * Show user(s) + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + let user; + + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).optional().get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Get 'userIds' parameter + const [userIds, userIdsErr] = $.arr($.type(ID)).optional().get(params.userIds); + if (userIdsErr) return rej('invalid userIds param'); + + // Get 'username' parameter + const [username, usernameErr] = $.str.optional().get(params.username); + if (usernameErr) return rej('invalid username param'); + + // Get 'host' parameter + const [host, hostErr] = $.str.optional().nullable().get(params.host); + if (hostErr) return rej('invalid host param'); + + if (userIds) { + const users = await User.find({ + _id: { + $in: userIds + } + }); + + res(await Promise.all(users.map(u => pack(u, me, { + detail: true + })))); + } else { + // Lookup user + if (typeof host === 'string') { + try { + user = await resolveRemoteUser(username, host, cursorOption); + } catch (e) { + console.warn(`failed to resolve remote user: ${e}`); + return rej('failed to resolve remote user'); + } + } else { + const q = userId !== undefined + ? { _id: userId } + : { usernameLower: username.toLowerCase(), host: null }; + + user = await User.findOne(q, cursorOption); + + if (user === null) { + return rej('user not found'); + } + } + + // Send response + res(await pack(user, me, { + detail: true + })); + } +}); |