diff options
Diffstat (limited to 'src/api')
107 files changed, 6689 insertions, 0 deletions
diff --git a/src/api/api-handler.ts b/src/api/api-handler.ts new file mode 100644 index 0000000000..c0714ad69a --- /dev/null +++ b/src/api/api-handler.ts @@ -0,0 +1,55 @@ +import * as express from 'express'; + +import { IEndpoint } from './endpoints'; +import authenticate from './authenticate'; +import { IAuthContext } from './authenticate'; +import _reply from './reply'; +import limitter from './limitter'; + +export default async (endpoint: IEndpoint, req: express.Request, res: express.Response) => { + const reply = _reply.bind(null, res); + let ctx: IAuthContext; + + // Authetication + try { + ctx = await authenticate(req); + } catch (e) { + return reply(403, 'AUTHENTICATION_FAILED'); + } + + if (endpoint.secure && !ctx.isSecure) { + return reply(403, 'ACCESS_DENIED'); + } + + if (endpoint.shouldBeSignin && ctx.user == null) { + return reply(401, 'PLZ_SIGNIN'); + } + + if (ctx.app && endpoint.kind) { + if (!ctx.app.permission.some((p: any) => p === endpoint.kind)) { + return reply(403, 'ACCESS_DENIED'); + } + } + + if (endpoint.shouldBeSignin) { + try { + await limitter(endpoint, ctx); // Rate limit + } catch (e) { + return reply(429); + } + } + + let exec = require(`${__dirname}/endpoints/${endpoint.name}`); + + if (endpoint.withFile) { + exec = exec.bind(null, req.file); + } + + // API invoking + try { + const res = await exec(req.body, ctx.user, ctx.app, ctx.isSecure); + reply(res); + } catch (e) { + reply(400, e); + } +}; diff --git a/src/api/authenticate.ts b/src/api/authenticate.ts new file mode 100644 index 0000000000..5798adb83d --- /dev/null +++ b/src/api/authenticate.ts @@ -0,0 +1,61 @@ +import * as express from 'express'; +import App from './models/app'; +import User from './models/user'; +import Userkey from './models/userkey'; + +export interface IAuthContext { + /** + * App which requested + */ + app: any; + + /** + * Authenticated user + */ + user: any; + + /** + * Weather if the request is via the (Misskey Web Client or user direct) or not + */ + isSecure: boolean; +} + +export default (req: express.Request) => + new Promise<IAuthContext>(async (resolve, reject) => { + const token = req.body['i']; + if (token) { + const user = await User + .findOne({ token: token }); + + if (user === null) { + return reject('user not found'); + } + + return resolve({ + app: null, + user: user, + isSecure: true + }); + } + + const userkey = req.headers['userkey'] || req.body['_userkey']; + if (userkey) { + const userkeyDoc = await Userkey.findOne({ + key: userkey + }); + + if (userkeyDoc === null) { + return reject('invalid userkey'); + } + + const app = await App + .findOne({ _id: userkeyDoc.app_id }); + + const user = await User + .findOne({ _id: userkeyDoc.user_id }); + + return resolve({ app: app, user: user, isSecure: false }); + } + + return resolve({ app: null, user: null, isSecure: false }); +}); diff --git a/src/api/common/add-file-to-drive.ts b/src/api/common/add-file-to-drive.ts new file mode 100644 index 0000000000..0bd9f34825 --- /dev/null +++ b/src/api/common/add-file-to-drive.ts @@ -0,0 +1,149 @@ +import * as mongodb from 'mongodb'; +import * as crypto from 'crypto'; +import * as gm from 'gm'; +const fileType = require('file-type'); +const prominence = require('prominence'); +import DriveFile from '../models/drive-file'; +import DriveFolder from '../models/drive-folder'; +import serialize from '../serializers/drive-file'; +import event from '../event'; + +/** + * Add file to drive + * + * @param user User who wish to add file + * @param fileName File name + * @param data Contents + * @param comment Comment + * @param type File type + * @param folderId Folder ID + * @param force If set to true, forcibly upload the file even if there is a file with the same hash. + * @return Object that represents added file + */ +export default ( + user: any, + data: Buffer, + name: string = null, + comment: string = null, + folderId: mongodb.ObjectID = null, + force: boolean = false +) => new Promise<any>(async (resolve, reject) => { + // File size + const size = data.byteLength; + + // File type + let mime = 'application/octet-stream'; + const type = fileType(data); + if (type !== null) { + mime = type.mime; + + if (name === null) { + name = `untitled.${type.ext}`; + } + } else { + if (name === null) { + name = 'untitled'; + } + } + + // Generate hash + const hash = crypto + .createHash('sha256') + .update(data) + .digest('hex') as string; + + if (!force) { + // Check if there is a file with the same hash and same data size (to be safe) + const much = await DriveFile.findOne({ + user_id: user._id, + hash: hash, + datasize: size + }); + + if (much !== null) { + resolve(much); + return; + } + } + + // Fetch all files to calculate drive usage + const files = await DriveFile + .find({ user_id: user._id }, { + datasize: true, + _id: false + }) + .toArray(); + + // Calculate drive usage (in byte) + const usage = files.map(file => file.datasize).reduce((x, y) => x + y, 0); + + // If usage limit exceeded + if (usage + size > user.drive_capacity) { + return reject('no-free-space'); + } + + // If the folder is specified + let folder: any = null; + if (folderId !== null) { + folder = await DriveFolder + .findOne({ + _id: folderId, + user_id: user._id + }); + + if (folder === null) { + return reject('folder-not-found'); + } + } + + let properties: any = null; + + // If the file is an image + if (/^image\/.*$/.test(mime)) { + // Calculate width and height to save in property + const g = gm(data, name); + const size = await prominence(g).size(); + properties = { + width: size.width, + height: size.height + }; + } + + // Create DriveFile document + const res = await DriveFile.insert({ + created_at: new Date(), + user_id: user._id, + folder_id: folder !== null ? folder._id : null, + data: data, + datasize: size, + type: mime, + name: name, + comment: comment, + hash: hash, + properties: properties + }); + + const file = res.ops[0]; + + resolve(file); + + // Serialize + const fileObj = await serialize(file); + + // Publish drive_file_created event + event(user._id, 'drive_file_created', fileObj); + + // Register to search database + if (config.elasticsearch.enable) { + const es = require('../../db/elasticsearch'); + es.index({ + index: 'misskey', + type: 'drive_file', + id: file._id.toString(), + body: { + name: file.name, + user_id: user._id.toString() + } + }); + } +}); diff --git a/src/api/common/get-friends.ts b/src/api/common/get-friends.ts new file mode 100644 index 0000000000..5d50bcdb13 --- /dev/null +++ b/src/api/common/get-friends.ts @@ -0,0 +1,25 @@ +import * as mongodb from 'mongodb'; +import Following from '../models/following'; + +export default async (me: mongodb.ObjectID, includeMe: boolean = true) => { + // Fetch relation to other users who the I follows + // SELECT followee + const myfollowing = await Following + .find({ + follower_id: me, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + followee_id: true + }) + .toArray(); + + // ID list of other users who the I follows + const myfollowingIds = myfollowing.map(follow => follow.followee_id); + + if (includeMe) { + myfollowingIds.push(me); + } + + return myfollowingIds; +}; diff --git a/src/api/common/notify.ts b/src/api/common/notify.ts new file mode 100644 index 0000000000..c4c94ee704 --- /dev/null +++ b/src/api/common/notify.ts @@ -0,0 +1,32 @@ +import * as mongo from 'mongodb'; +import Notification from '../models/notification'; +import event from '../event'; +import serialize from '../serializers/notification'; + +export default ( + notifiee: mongo.ObjectID, + notifier: mongo.ObjectID, + type: string, + content: any +) => new Promise<any>(async (resolve, reject) => { + if (notifiee.equals(notifier)) { + return resolve(); + } + + // Create notification + const res = await Notification.insert(Object.assign({ + created_at: new Date(), + notifiee_id: notifiee, + notifier_id: notifier, + type: type, + is_read: false + }, content)); + + const notification = res.ops[0]; + + resolve(notification); + + // Publish notification event + event(notifiee, 'notification', + await serialize(notification)); +}); diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts new file mode 100644 index 0000000000..ad45f42bc7 --- /dev/null +++ b/src/api/endpoints.ts @@ -0,0 +1,101 @@ +const second = 1000; +const minute = 60 * second; +const hour = 60 * minute; +const day = 24 * hour; + +export interface IEndpoint { + name: string; + shouldBeSignin: boolean; + limitKey?: string; + limitDuration?: number; + limitMax?: number; + minInterval?: number; + withFile?: boolean; + secure?: boolean; + kind?: string; +} + +export default [ + { name: 'meta', shouldBeSignin: false }, + + { name: 'username/available', shouldBeSignin: false }, + + { name: 'my/apps', shouldBeSignin: true }, + + { name: 'app/create', shouldBeSignin: true, limitDuration: day, limitMax: 3 }, + { name: 'app/show', shouldBeSignin: false }, + { name: 'app/name_id/available', shouldBeSignin: false }, + + { name: 'auth/session/generate', shouldBeSignin: false }, + { name: 'auth/session/show', shouldBeSignin: false }, + { name: 'auth/session/userkey', shouldBeSignin: false }, + { name: 'auth/accept', shouldBeSignin: true, secure: true }, + { name: 'auth/deny', shouldBeSignin: true, secure: true }, + + { name: 'aggregation/users/post', shouldBeSignin: false }, + { name: 'aggregation/users/like', shouldBeSignin: false }, + { name: 'aggregation/users/followers', shouldBeSignin: false }, + { name: 'aggregation/users/following', shouldBeSignin: false }, + { name: 'aggregation/posts/like', shouldBeSignin: false }, + { name: 'aggregation/posts/likes', shouldBeSignin: false }, + { name: 'aggregation/posts/repost', shouldBeSignin: false }, + { name: 'aggregation/posts/reply', shouldBeSignin: false }, + + { name: 'i', shouldBeSignin: true }, + { name: 'i/update', shouldBeSignin: true, limitDuration: day, limitMax: 50, kind: 'account-write' }, + { name: 'i/appdata/get', shouldBeSignin: true }, + { name: 'i/appdata/set', shouldBeSignin: true }, + { name: 'i/signin_history', shouldBeSignin: true, kind: 'account-read' }, + + { name: 'i/notifications', shouldBeSignin: true, kind: 'notification-read' }, + { name: 'notifications/delete', shouldBeSignin: true, kind: 'notification-write' }, + { name: 'notifications/delete_all', shouldBeSignin: true, kind: 'notification-write' }, + { name: 'notifications/mark_as_read', shouldBeSignin: true, kind: 'notification-write' }, + { name: 'notifications/mark_as_read_all', shouldBeSignin: true, kind: 'notification-write' }, + + { name: 'drive', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/stream', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/files', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/files/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, withFile: true, kind: 'drive-write' }, + { name: 'drive/files/show', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/files/find', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/files/delete', shouldBeSignin: true, kind: 'drive-write' }, + { name: 'drive/files/update', shouldBeSignin: true, kind: 'drive-write' }, + { name: 'drive/folders', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/folders/create', shouldBeSignin: true, limitDuration: hour, limitMax: 50, kind: 'drive-write' }, + { name: 'drive/folders/show', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/folders/find', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/folders/update', shouldBeSignin: true, kind: 'drive-write' }, + + { name: 'users', shouldBeSignin: false }, + { name: 'users/show', shouldBeSignin: false }, + { name: 'users/search', shouldBeSignin: false }, + { name: 'users/search_by_username', shouldBeSignin: false }, + { name: 'users/posts', shouldBeSignin: false }, + { name: 'users/following', shouldBeSignin: false }, + { name: 'users/followers', shouldBeSignin: false }, + { name: 'users/recommendation', shouldBeSignin: true, kind: 'account-read' }, + + { name: 'following/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'following-write' }, + { name: 'following/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'following-write' }, + + { name: 'posts/show', shouldBeSignin: false }, + { name: 'posts/replies', shouldBeSignin: false }, + { name: 'posts/context', shouldBeSignin: false }, + { name: 'posts/create', shouldBeSignin: true, limitDuration: hour, limitMax: 120, minInterval: 1 * second, kind: 'post-write' }, + { name: 'posts/reposts', shouldBeSignin: false }, + { name: 'posts/search', shouldBeSignin: false }, + { name: 'posts/timeline', shouldBeSignin: true, limitDuration: 10 * minute, limitMax: 100 }, + { name: 'posts/mentions', shouldBeSignin: true, limitDuration: 10 * minute, limitMax: 100 }, + { name: 'posts/likes', shouldBeSignin: true }, + { name: 'posts/likes/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'like-write' }, + { name: 'posts/likes/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'like-write' }, + { name: 'posts/favorites/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'favorite-write' }, + { name: 'posts/favorites/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'favorite-write' }, + + { name: 'messaging/history', shouldBeSignin: true, kind: 'messaging-read' }, + { name: 'messaging/unread', shouldBeSignin: true, kind: 'messaging-read' }, + { name: 'messaging/messages', shouldBeSignin: true, kind: 'messaging-read' }, + { name: 'messaging/messages/create', shouldBeSignin: true, kind: 'messaging-write' } + +] as IEndpoint[]; diff --git a/src/api/endpoints/aggregation/posts/like.js b/src/api/endpoints/aggregation/posts/like.js new file mode 100644 index 0000000000..b82c494ff1 --- /dev/null +++ b/src/api/endpoints/aggregation/posts/like.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../../models/post'; +import Like from '../../../models/like'; + +/** + * Aggregate like of a post + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + const datas = await Like + .aggregate([ + { $match: { post_id: post._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let 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/api/endpoints/aggregation/posts/likes.js b/src/api/endpoints/aggregation/posts/likes.js new file mode 100644 index 0000000000..0317245159 --- /dev/null +++ b/src/api/endpoints/aggregation/posts/likes.js @@ -0,0 +1,76 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../../models/post'; +import Like from '../../../models/like'; + +/** + * Aggregate likes of a post + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1)); + + const likes = await Like + .find({ + post_id: post._id, + $or: [ + { deleted_at: { $exists: false } }, + { deleted_at: { $gt: startTime } } + ] + }, { + _id: false, + post_id: false + }, { + sort: { created_at: -1 } + }) + .toArray(); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + day = new Date(day.setMilliseconds(999)); + day = new Date(day.setSeconds(59)); + day = new Date(day.setMinutes(59)); + day = new Date(day.setHours(23)); + //day = day.getTime(); + + const count = likes.filter(l => + l.created_at < day && (l.deleted_at == null || l.deleted_at > day) + ).length; + + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: count + }); + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/posts/reply.js b/src/api/endpoints/aggregation/posts/reply.js new file mode 100644 index 0000000000..e578bc6d7d --- /dev/null +++ b/src/api/endpoints/aggregation/posts/reply.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../../models/post'; + +/** + * Aggregate reply of a post + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + const datas = await Post + .aggregate([ + { $match: { reply_to: post._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let 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/api/endpoints/aggregation/posts/repost.js b/src/api/endpoints/aggregation/posts/repost.js new file mode 100644 index 0000000000..38d63442a8 --- /dev/null +++ b/src/api/endpoints/aggregation/posts/repost.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../../models/post'; + +/** + * Aggregate repost of a post + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + const datas = await Post + .aggregate([ + { $match: { repost_id: post._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let 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/api/endpoints/aggregation/users/followers.js b/src/api/endpoints/aggregation/users/followers.js new file mode 100644 index 0000000000..16dda09675 --- /dev/null +++ b/src/api/endpoints/aggregation/users/followers.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../../models/user'; +import Following from '../../../models/following'; + +/** + * Aggregate followers of a user + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1)); + + const following = await Following + .find({ + followee_id: user._id, + $or: [ + { deleted_at: { $exists: false } }, + { deleted_at: { $gt: startTime } } + ] + }, { + _id: false, + follower_id: false, + followee_id: false + }, { + sort: { created_at: -1 } + }) + .toArray(); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + day = new Date(day.setMilliseconds(999)); + day = new Date(day.setSeconds(59)); + day = new Date(day.setMinutes(59)); + day = new Date(day.setHours(23)); + // day = day.getTime(); + + const count = following.filter(f => + f.created_at < day && (f.deleted_at == null || f.deleted_at > day) + ).length; + + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: count + }); + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/users/following.js b/src/api/endpoints/aggregation/users/following.js new file mode 100644 index 0000000000..7b7448d715 --- /dev/null +++ b/src/api/endpoints/aggregation/users/following.js @@ -0,0 +1,76 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../../models/user'; +import Following from '../../../models/following'; + +/** + * Aggregate following of a user + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1)); + + const following = await Following + .find({ + follower_id: user._id, + $or: [ + { deleted_at: { $exists: false } }, + { deleted_at: { $gt: startTime } } + ] + }, { + _id: false, + follower_id: false, + followee_id: false + }, { + sort: { created_at: -1 } + }) + .toArray(); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + day = new Date(day.setMilliseconds(999)); + day = new Date(day.setSeconds(59)); + day = new Date(day.setMinutes(59)); + day = new Date(day.setHours(23)); + + const count = following.filter(f => + f.created_at < day && (f.deleted_at == null || f.deleted_at > day) + ).length; + + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: count + }); + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/users/like.js b/src/api/endpoints/aggregation/users/like.js new file mode 100644 index 0000000000..830f1f1bba --- /dev/null +++ b/src/api/endpoints/aggregation/users/like.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../../models/user'; +import Like from '../../../models/like'; + +/** + * Aggregate like of a user + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Like + .aggregate([ + { $match: { user_id: user._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let 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/api/endpoints/aggregation/users/post.js b/src/api/endpoints/aggregation/users/post.js new file mode 100644 index 0000000000..d75df30f5d --- /dev/null +++ b/src/api/endpoints/aggregation/users/post.js @@ -0,0 +1,113 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../../models/user'; +import Post from '../../../models/post'; + +/** + * Aggregate post of a user + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Post + .aggregate([ + { $match: { user_id: user._id } }, + { $project: { + repost_id: '$repost_id', + reply_to_id: '$reply_to_id', + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + }, + type: { + $cond: { + if: { $ne: ['$repost_id', null] }, + then: 'repost', + else: { + $cond: { + if: { $ne: ['$reply_to_id', null] }, + then: 'reply', + else: 'post' + } + } + } + }} + }, + { $group: { _id: { + date: '$date', + type: '$type' + }, count: { $sum: 1 } } }, + { $group: { + _id: '$_id.date', + data: { $addToSet: { + type: '$_id.type', + count: '$count' + }} + } } + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + + data.posts = (data.data.filter(x => x.type == 'post')[0] || { count: 0 }).count; + data.reposts = (data.data.filter(x => x.type == 'repost')[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++) { + let 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() + }, + posts: 0, + reposts: 0, + replies: 0 + }) + }; + } + + res(graph); +}); diff --git a/src/api/endpoints/app/create.js b/src/api/endpoints/app/create.js new file mode 100644 index 0000000000..d83062c8e9 --- /dev/null +++ b/src/api/endpoints/app/create.js @@ -0,0 +1,75 @@ +'use strict'; + +/** + * Module dependencies + */ +import rndstr from 'rndstr'; +import App from '../../models/app'; +import serialize from '../../serializers/app'; + +/** + * Create an app + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = async (params, user) => + new Promise(async (res, rej) => +{ + // Get 'name_id' parameter + const nameId = params.name_id; + if (nameId == null || nameId == '') { + return rej('name_id is required'); + } + + // Validate name_id + if (!/^[a-zA-Z0-9\-]{3,30}$/.test(nameId)) { + return rej('invalid name_id'); + } + + // Get 'name' parameter + const name = params.name; + if (name == null || name == '') { + return rej('name is required'); + } + + // Get 'description' parameter + const description = params.description; + if (description == null || description == '') { + return rej('description is required'); + } + + // Get 'permission' parameter + const permission = params.permission; + if (permission == null || permission == '') { + return rej('permission is required'); + } + + // Get 'callback_url' parameter + let callback = params.callback_url; + if (callback === '') { + callback = null; + } + + // Generate secret + const secret = rndstr('a-zA-Z0-9', 32); + + // Create account + const inserted = await App.insert({ + created_at: new Date(), + user_id: user._id, + name: name, + name_id: nameId, + name_id_lower: nameId.toLowerCase(), + description: description, + permission: permission.split(','), + callback_url: callback, + secret: secret + }); + + const app = inserted.ops[0]; + + // Response + res(await serialize(app)); +}); diff --git a/src/api/endpoints/app/name_id/available.js b/src/api/endpoints/app/name_id/available.js new file mode 100644 index 0000000000..179925dce4 --- /dev/null +++ b/src/api/endpoints/app/name_id/available.js @@ -0,0 +1,40 @@ +'use strict'; + +/** + * Module dependencies + */ +import App from '../../../models/app'; + +/** + * Check available name_id of app + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = async (params) => + new Promise(async (res, rej) => +{ + // Get 'name_id' parameter + const nameId = params.name_id; + if (nameId == null || nameId == '') { + return rej('name_id is required'); + } + + // Validate name_id + if (!/^[a-zA-Z0-9\-]{3,30}$/.test(nameId)) { + return rej('invalid name_id'); + } + + // Get exist + const exist = await App + .count({ + name_id_lower: nameId.toLowerCase() + }, { + limit: 1 + }); + + // Reply + res({ + available: exist === 0 + }); +}); diff --git a/src/api/endpoints/app/show.js b/src/api/endpoints/app/show.js new file mode 100644 index 0000000000..8d12f9aeb1 --- /dev/null +++ b/src/api/endpoints/app/show.js @@ -0,0 +1,51 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import App from '../../models/app'; +import serialize from '../../serializers/app'; + +/** + * Show an app + * + * @param {Object} params + * @param {Object} user + * @param {Object} _ + * @param {Object} isSecure + * @return {Promise<object>} + */ +module.exports = (params, user, _, isSecure) => + new Promise(async (res, rej) => +{ + // Get 'app_id' parameter + let appId = params.app_id; + if (appId == null || appId == '') { + appId = null; + } + + // Get 'name_id' parameter + let nameId = params.name_id; + if (nameId == null || nameId == '') { + nameId = null; + } + + if (appId === null && nameId === null) { + return rej('app_id or name_id is required'); + } + + // Lookup app + const app = appId !== null + ? await App.findOne({ _id: new mongo.ObjectID(appId) }) + : await App.findOne({ name_id_lower: nameId.toLowerCase() }); + + if (app === null) { + return rej('app not found'); + } + + // Send response + res(await serialize(app, user, { + includeSecret: isSecure && app.user_id.equals(user._id) + })); +}); diff --git a/src/api/endpoints/auth/accept.js b/src/api/endpoints/auth/accept.js new file mode 100644 index 0000000000..7c45650c6b --- /dev/null +++ b/src/api/endpoints/auth/accept.js @@ -0,0 +1,64 @@ +'use strict'; + +/** + * Module dependencies + */ +import rndstr from 'rndstr'; +import AuthSess from '../../models/auth-session'; +import Userkey from '../../models/userkey'; + +/** + * Accept + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'token' parameter + const token = params.token; + if (token == null) { + return rej('token is required'); + } + + // Fetch token + const session = await AuthSess + .findOne({ token: token }); + + if (session === null) { + return rej('session not found'); + } + + // Generate userkey + const key = rndstr('a-zA-Z0-9', 32); + + // Fetch exist userkey + const exist = await Userkey.findOne({ + app_id: session.app_id, + user_id: user._id, + }); + + if (exist === null) { + // Insert userkey doc + await Userkey.insert({ + created_at: new Date(), + app_id: session.app_id, + user_id: user._id, + key: key + }); + } + + // Update session + await AuthSess.updateOne({ + _id: session._id + }, { + $set: { + user_id: user._id + } + }); + + // Response + res(); +}); diff --git a/src/api/endpoints/auth/session/generate.js b/src/api/endpoints/auth/session/generate.js new file mode 100644 index 0000000000..bb49cf090d --- /dev/null +++ b/src/api/endpoints/auth/session/generate.js @@ -0,0 +1,51 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as uuid from 'uuid'; +import App from '../../../models/app'; +import AuthSess from '../../../models/auth-session'; + +/** + * Generate a session + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'app_secret' parameter + const appSecret = params.app_secret; + if (appSecret == null) { + return rej('app_secret is required'); + } + + // 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 inserted = await AuthSess.insert({ + created_at: new Date(), + app_id: app._id, + token: token + }); + + const doc = inserted.ops[0]; + + // Response + res({ + token: doc.token, + url: `${config.auth_url}/${doc.token}` + }); +}); diff --git a/src/api/endpoints/auth/session/show.js b/src/api/endpoints/auth/session/show.js new file mode 100644 index 0000000000..67160c6993 --- /dev/null +++ b/src/api/endpoints/auth/session/show.js @@ -0,0 +1,36 @@ +'use strict'; + +/** + * Module dependencies + */ +import AuthSess from '../../../models/auth-session'; +import serialize from '../../../serializers/auth-session'; + +/** + * Show a session + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'token' parameter + const token = params.token; + if (token == null) { + return rej('token is required'); + } + + // Lookup session + const session = await AuthSess.findOne({ + token: token + }); + + if (session == null) { + return rej('session not found'); + } + + // Response + res(await serialize(session, user)); +}); diff --git a/src/api/endpoints/auth/session/userkey.js b/src/api/endpoints/auth/session/userkey.js new file mode 100644 index 0000000000..2626e4ce39 --- /dev/null +++ b/src/api/endpoints/auth/session/userkey.js @@ -0,0 +1,74 @@ +'use strict'; + +/** + * Module dependencies + */ +import App from '../../../models/app'; +import AuthSess from '../../../models/auth-session'; +import Userkey from '../../../models/userkey'; +import serialize from '../../../serializers/user'; + +/** + * Generate a session + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'app_secret' parameter + const appSecret = params.app_secret; + if (appSecret == null) { + return rej('app_secret is required'); + } + + // Lookup app + const app = await App.findOne({ + secret: appSecret + }); + + if (app == null) { + return rej('app not found'); + } + + // Get 'token' parameter + const token = params.token; + if (token == null) { + return rej('token is required'); + } + + // Fetch token + const session = await AuthSess + .findOne({ + token: token, + app_id: app._id + }); + + if (session === null) { + return rej('session not found'); + } + + if (session.user_id == null) { + return rej('this session is not allowed yet'); + } + + // Lookup userkey + const userkey = await Userkey.findOne({ + app_id: app._id, + user_id: session.user_id + }); + + // Delete session + AuthSess.deleteOne({ + _id: session._id + }); + + // Response + res({ + userkey: userkey.key, + user: await serialize(session.user_id, null, { + detail: true + }) + }); +}); diff --git a/src/api/endpoints/drive.js b/src/api/endpoints/drive.js new file mode 100644 index 0000000000..4df4ac33fa --- /dev/null +++ b/src/api/endpoints/drive.js @@ -0,0 +1,33 @@ +'use strict'; + +/** + * Module dependencies + */ +import DriveFile from './models/drive-file'; + +/** + * Get drive information + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Fetch all files to calculate drive usage + const files = await DriveFile + .find({ user_id: user._id }, { + datasize: true, + _id: false + }) + .toArray(); + + // Calculate drive usage (in byte) + const usage = files.map(file => file.datasize).reduce((x, y) => x + y, 0); + + res({ + capacity: user.drive_capacity, + usage: usage + }); +}); diff --git a/src/api/endpoints/drive/files.js b/src/api/endpoints/drive/files.js new file mode 100644 index 0000000000..7e8ff59f2a --- /dev/null +++ b/src/api/endpoints/drive/files.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../../models/drive-file'; +import serialize from '../../serializers/drive-file'; + +/** + * Get drive files + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @return {Promise<object>} + */ +module.exports = (params, user, app) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Get 'folder_id' parameter + let folder = params.folder_id; + if (folder === undefined || folder === null || folder === 'null') { + folder = null; + } else { + folder = new mongo.ObjectID(folder); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = { + user_id: user._id, + folder_id: folder + }; + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const files = await DriveFile + .find(query, { + data: false + }, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(files.map(async file => + await serialize(file)))); +}); diff --git a/src/api/endpoints/drive/files/create.js b/src/api/endpoints/drive/files/create.js new file mode 100644 index 0000000000..5966499c59 --- /dev/null +++ b/src/api/endpoints/drive/files/create.js @@ -0,0 +1,59 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as fs from 'fs'; +import * as mongo from 'mongodb'; +import File from '../../../models/drive-file'; +import { validateFileName } from '../../../models/drive-file'; +import User from '../../../models/user'; +import serialize from '../../../serializers/drive-file'; +import create from '../../../common/add-file-to-drive'; + +/** + * Create a file + * + * @param {Object} file + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (file, params, user) => + new Promise(async (res, rej) => +{ + const buffer = fs.readFileSync(file.path); + fs.unlink(file.path); + + // 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)) { + return rej('invalid name'); + } + } else { + name = null; + } + + // Get 'folder_id' parameter + let folder = params.folder_id; + if (folder === undefined || folder === null || folder === 'null') { + folder = null; + } else { + folder = new mongo.ObjectID(folder); + } + + // Create file + const driveFile = await create(user, buffer, name, null, folder); + + // Serialize + const fileObj = await serialize(driveFile); + + // Response + res(fileObj); +}); diff --git a/src/api/endpoints/drive/files/find.js b/src/api/endpoints/drive/files/find.js new file mode 100644 index 0000000000..e4e4c230d2 --- /dev/null +++ b/src/api/endpoints/drive/files/find.js @@ -0,0 +1,48 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../../../models/drive-file'; +import serialize from '../../../serializers/drive-file'; + +/** + * Find a file(s) + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'name' parameter + const name = params.name; + if (name === undefined || name === null) { + return rej('name is required'); + } + + // Get 'folder_id' parameter + let folder = params.folder_id; + if (folder === undefined || folder === null || folder === 'null') { + folder = null; + } else { + folder = new mongo.ObjectID(folder); + } + + // Issue query + const files = await DriveFile + .find({ + name: name, + user_id: user._id, + folder_id: folder + }, { + data: false + }) + .toArray(); + + // Serialize + res(await Promise.all(files.map(async file => + await serialize(file)))); +}); diff --git a/src/api/endpoints/drive/files/show.js b/src/api/endpoints/drive/files/show.js new file mode 100644 index 0000000000..79b07dace2 --- /dev/null +++ b/src/api/endpoints/drive/files/show.js @@ -0,0 +1,40 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../../../models/drive-file'; +import serialize from '../../../serializers/drive-file'; + +/** + * Show a file + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'file_id' parameter + const fileId = params.file_id; + if (fileId === undefined || fileId === null) { + return rej('file_id is required'); + } + + const file = await DriveFile + .findOne({ + _id: new mongo.ObjectID(fileId), + user_id: user._id + }, { + data: false + }); + + if (file === null) { + return rej('file-not-found'); + } + + // Serialize + res(await serialize(file)); +}); diff --git a/src/api/endpoints/drive/files/update.js b/src/api/endpoints/drive/files/update.js new file mode 100644 index 0000000000..bbcb10b42d --- /dev/null +++ b/src/api/endpoints/drive/files/update.js @@ -0,0 +1,89 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import DriveFile from '../../../models/drive-file'; +import { validateFileName } from '../../../models/drive-file'; +import serialize from '../../../serializers/drive-file'; +import event from '../../../event'; + +/** + * Update a file + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'file_id' parameter + const fileId = params.file_id; + if (fileId === undefined || fileId === null) { + return rej('file_id is required'); + } + + const file = await DriveFile + .findOne({ + _id: new mongo.ObjectID(fileId), + user_id: user._id + }, { + data: false + }); + + if (file === null) { + return rej('file-not-found'); + } + + // Get 'name' parameter + let name = params.name; + if (name) { + name = name.trim(); + if (validateFileName(name)) { + file.name = name; + } else { + return rej('invalid file name'); + } + } + + // Get 'folder_id' parameter + let folderId = params.folder_id; + if (folderId !== undefined && folderId !== 'null') { + folderId = new mongo.ObjectID(folderId); + } + + let folder = null; + if (folderId !== undefined && folderId !== null) { + if (folderId === 'null') { + file.folder_id = null; + } else { + folder = await DriveFolder + .findOne({ + _id: folderId, + user_id: user._id + }); + + if (folder === null) { + return reject('folder-not-found'); + } + + file.folder_id = folder._id; + } + } + + DriveFile.updateOne({ _id: file._id }, { + $set: file + }); + + // Serialize + const fileObj = await serialize(file); + + // Response + res(fileObj); + + // Publish drive_file_updated event + event(user._id, 'drive_file_updated', fileObj); +}); diff --git a/src/api/endpoints/drive/folders.js b/src/api/endpoints/drive/folders.js new file mode 100644 index 0000000000..f95a60036f --- /dev/null +++ b/src/api/endpoints/drive/folders.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../models/drive-folder'; +import serialize from '../../serializers/drive-folder'; + +/** + * Get drive folders + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @return {Promise<object>} + */ +module.exports = (params, user, app) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Get 'folder_id' parameter + let folder = params.folder_id; + if (folder === undefined || folder === null || folder === 'null') { + folder = null; + } else { + folder = new mongo.ObjectID(folder); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = { + user_id: user._id, + parent_id: folder + }; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const folders = await DriveFolder + .find(query, { + data: false + }, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(folders.map(async folder => + await serialize(folder)))); +}); diff --git a/src/api/endpoints/drive/folders/create.js b/src/api/endpoints/drive/folders/create.js new file mode 100644 index 0000000000..ba40d1763e --- /dev/null +++ b/src/api/endpoints/drive/folders/create.js @@ -0,0 +1,79 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import { isValidFolderName } from '../../../models/drive-folder'; +import serialize from '../../../serializers/drive-folder'; +import event from '../../../event'; + +/** + * Create drive folder + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'name' parameter + let name = params.name; + if (name !== undefined && name !== null) { + name = name.trim(); + if (name.length === 0) { + name = null; + } else if (!isValidFolderName(name)) { + return rej('invalid name'); + } + } else { + name = null; + } + + if (name == null) { + name = '無題のフォルダー'; + } + + // Get 'folder_id' parameter + let parentId = params.folder_id; + if (parentId === undefined || parentId === null) { + parentId = null; + } else { + parentId = new mongo.ObjectID(parentId); + } + + // If the parent folder is specified + let parent = null; + if (parentId !== null) { + parent = await DriveFolder + .findOne({ + _id: parentId, + user_id: user._id + }); + + if (parent === null) { + return reject('parent-not-found'); + } + } + + // Create folder + const inserted = await DriveFolder.insert({ + created_at: new Date(), + name: name, + parent_id: parent !== null ? parent._id : null, + user_id: user._id + }); + + const folder = inserted.ops[0]; + + // Serialize + const folderObj = await serialize(folder); + + // Response + res(folderObj); + + // Publish drive_folder_created event + event(user._id, 'drive_folder_created', folderObj); +}); diff --git a/src/api/endpoints/drive/folders/find.js b/src/api/endpoints/drive/folders/find.js new file mode 100644 index 0000000000..01805dc910 --- /dev/null +++ b/src/api/endpoints/drive/folders/find.js @@ -0,0 +1,46 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import serialize from '../../../serializers/drive-folder'; + +/** + * Find a folder(s) + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'name' parameter + const name = params.name; + if (name === undefined || name === null) { + return rej('name is required'); + } + + // Get 'parent_id' parameter + let parentId = params.parent_id; + if (parentId === undefined || parentId === null || parentId === 'null') { + parentId = null; + } else { + parentId = new mongo.ObjectID(parentId); + } + + // Issue query + const folders = await DriveFolder + .find({ + name: name, + user_id: user._id, + parent_id: parentId + }) + .toArray(); + + // Serialize + res(await Promise.all(folders.map(async folder => + await serialize(folder)))); +}); diff --git a/src/api/endpoints/drive/folders/show.js b/src/api/endpoints/drive/folders/show.js new file mode 100644 index 0000000000..4424361a87 --- /dev/null +++ b/src/api/endpoints/drive/folders/show.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import serialize from '../../../serializers/drive-folder'; + +/** + * Show a folder + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'folder_id' parameter + const folderId = params.folder_id; + if (folderId === undefined || folderId === null) { + return rej('folder_id is required'); + } + + // Get folder + const folder = await DriveFolder + .findOne({ + _id: new mongo.ObjectID(folderId), + user_id: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + // Serialize + res(await serialize(folder, { + includeParent: true + })); +}); diff --git a/src/api/endpoints/drive/folders/update.js b/src/api/endpoints/drive/folders/update.js new file mode 100644 index 0000000000..ff26a09aae --- /dev/null +++ b/src/api/endpoints/drive/folders/update.js @@ -0,0 +1,114 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import { isValidFolderName } from '../../../models/drive-folder'; +import serialize from '../../../serializers/drive-file'; +import event from '../../../event'; + +/** + * Update a folder + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'folder_id' parameter + const folderId = params.folder_id; + if (folderId === undefined || folderId === null) { + return rej('folder_id is required'); + } + + // Fetch folder + const folder = await DriveFolder + .findOne({ + _id: new mongo.ObjectID(folderId), + user_id: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + // Get 'name' parameter + let name = params.name; + if (name) { + name = name.trim(); + if (isValidFolderName(name)) { + folder.name = name; + } else { + return rej('invalid folder name'); + } + } + + // Get 'parent_id' parameter + let parentId = params.parent_id; + if (parentId !== undefined && parentId !== 'null') { + parentId = new mongo.ObjectID(parentId); + } + + let parent = null; + if (parentId !== undefined && parentId !== null) { + if (parentId === 'null') { + folder.parent_id = null; + } else { + // Get parent folder + parent = await DriveFolder + .findOne({ + _id: parentId, + user_id: user._id + }); + + if (parent === null) { + return rej('parent-folder-not-found'); + } + + // Check if the circular reference will be occured + async function checkCircle(folderId) { + // Fetch folder + const folder2 = await DriveFolder.findOne({ + _id: folderId + }, { + _id: true, + parent_id: true + }); + + if (folder2._id.equals(folder._id)) { + return true; + } else if (folder2.parent_id) { + return await checkCircle(folder2.parent_id); + } else { + return false; + } + } + + if (parent.parent_id !== null) { + if (await checkCircle(parent.parent_id)) { + return rej('detected-circular-definition'); + } + } + + folder.parent_id = parent._id; + } + } + + // Update + DriveFolder.updateOne({ _id: folder._id }, { + $set: folder + }); + + // Serialize + const folderObj = await serialize(folder); + + // Response + res(folderObj); + + // Publish drive_folder_updated event + event(user._id, 'drive_folder_updated', folderObj); +}); diff --git a/src/api/endpoints/drive/stream.js b/src/api/endpoints/drive/stream.js new file mode 100644 index 0000000000..0f407f5591 --- /dev/null +++ b/src/api/endpoints/drive/stream.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../../models/drive-file'; +import serialize from '../../serializers/drive-file'; + +/** + * Get drive stream + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Get 'type' parameter + let type = params.type; + if (type === undefined || type === null) { + type = null; + } else if (!/^[a-zA-Z\/\-\*]+$/.test(type)) { + return rej('invalid type format'); + } else { + type = new RegExp(`^${type.replace(/\*/g, '.+?')}$`); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = { + user_id: user._id + }; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + if (type !== null) { + query.type = type; + } + + // Issue query + const files = await DriveFile + .find(query, { + data: false + }, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(files.map(async file => + await serialize(file)))); +}); diff --git a/src/api/endpoints/following/create.js b/src/api/endpoints/following/create.js new file mode 100644 index 0000000000..da714cb180 --- /dev/null +++ b/src/api/endpoints/following/create.js @@ -0,0 +1,86 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import Following from '../../models/following'; +import notify from '../../common/notify'; +import event from '../../event'; +import serializeUser from '../../serializers/user'; + +/** + * Follow a user + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + const follower = user; + + // Get 'user_id' parameter + let userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // 自分自身 + if (user._id.equals(userId)) { + return rej('followee is yourself'); + } + + // Get followee + const followee = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (followee === null) { + return rej('user not found'); + } + + // Check arleady following + const exist = await Following.findOne({ + follower_id: follower._id, + followee_id: followee._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already following'); + } + + // Create following + await Following.insert({ + created_at: new Date(), + follower_id: follower._id, + followee_id: followee._id + }); + + // Send response + res(); + + // Increment following count + User.updateOne({ _id: follower._id }, { + $inc: { + following_count: 1 + } + }); + + // Increment followers count + User.updateOne({ _id: followee._id }, { + $inc: { + followers_count: 1 + } + }); + + // Publish follow event + event(follower._id, 'follow', await serializeUser(followee, follower)); + event(followee._id, 'followed', await serializeUser(follower, followee)); + + // Notify + notify(followee._id, follower._id, 'follow'); +}); diff --git a/src/api/endpoints/following/delete.js b/src/api/endpoints/following/delete.js new file mode 100644 index 0000000000..f1096801b6 --- /dev/null +++ b/src/api/endpoints/following/delete.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import Following from '../../models/following'; +import event from '../../event'; +import serializeUser from '../../serializers/user'; + +/** + * Unfollow a user + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + const follower = user; + + // Get 'user_id' parameter + let userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Check if the followee is yourself + if (user._id.equals(userId)) { + return rej('followee is yourself'); + } + + // Get followee + const followee = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (followee === null) { + return rej('user not found'); + } + + // Check not following + const exist = await Following.findOne({ + follower_id: follower._id, + followee_id: followee._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not following'); + } + + // Delete following + await Following.updateOne({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); + + // Decrement following count + User.updateOne({ _id: follower._id }, { + $inc: { + following_count: -1 + } + }); + + // Decrement followers count + User.updateOne({ _id: followee._id }, { + $inc: { + followers_count: -1 + } + }); + + // Publish follow event + event(follower._id, 'unfollow', await serializeUser(followee, follower)); +}); diff --git a/src/api/endpoints/i.js b/src/api/endpoints/i.js new file mode 100644 index 0000000000..481ddbb9fa --- /dev/null +++ b/src/api/endpoints/i.js @@ -0,0 +1,25 @@ +'use strict'; + +/** + * Module dependencies + */ +import serialize from '../serializers/user'; + +/** + * Show myself + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @param {Boolean} isSecure + * @return {Promise<object>} + */ +module.exports = (params, user, _, isSecure) => + new Promise(async (res, rej) => +{ + // Serialize + res(await serialize(user, user, { + detail: true, + includeSecrets: isSecure + })); +}); diff --git a/src/api/endpoints/i/appdata/get.js b/src/api/endpoints/i/appdata/get.js new file mode 100644 index 0000000000..0a86697469 --- /dev/null +++ b/src/api/endpoints/i/appdata/get.js @@ -0,0 +1,53 @@ +'use strict'; + +/** + * Module dependencies + */ +import Appdata from '../../../models/appdata'; + +/** + * Get app data + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @param {Boolean} isSecure + * @return {Promise<object>} + */ +module.exports = (params, user, app, isSecure) => + new Promise(async (res, rej) => +{ + // Get 'key' parameter + let key = params.key; + if (key === undefined) { + key = null; + } + + if (isSecure) { + if (!user.data) { + return res(); + } + if (key !== null) { + const data = {}; + data[key] = user.data[key]; + res(data); + } else { + res(user.data); + } + } else { + const select = {}; + if (key !== null) { + select['data.' + key] = true; + } + const appdata = await Appdata.findOne({ + app_id: app._id, + user_id: user._id + }, select); + + if (appdata) { + res(appdata.data); + } else { + res(); + } + } +}); diff --git a/src/api/endpoints/i/appdata/set.js b/src/api/endpoints/i/appdata/set.js new file mode 100644 index 0000000000..e161a803d0 --- /dev/null +++ b/src/api/endpoints/i/appdata/set.js @@ -0,0 +1,55 @@ +'use strict'; + +/** + * Module dependencies + */ +import Appdata from '../../../models/appdata'; +import User from '../../../models/user'; + +/** + * Set app data + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @param {Boolean} isSecure + * @return {Promise<object>} + */ +module.exports = (params, user, app, isSecure) => + new Promise(async (res, rej) => +{ + const data = params.data; + if (data == null) { + return rej('data is required'); + } + + if (isSecure) { + const set = { + $set: { + data: Object.assign(user.data || {}, JSON.parse(data)) + } + }; + await User.updateOne({ _id: user._id }, set); + res(204); + } else { + const appdata = await Appdata.findOne({ + app_id: app._id, + user_id: user._id + }); + const set = { + $set: { + data: Object.assign((appdata || {}).data || {}, JSON.parse(data)) + } + }; + await Appdata.updateOne({ + app_id: app._id, + user_id: user._id + }, Object.assign({ + app_id: app._id, + user_id: user._id + }, set), { + upsert: true + }); + res(204); + } +}); diff --git a/src/api/endpoints/i/favorites.js b/src/api/endpoints/i/favorites.js new file mode 100644 index 0000000000..e30ea2867b --- /dev/null +++ b/src/api/endpoints/i/favorites.js @@ -0,0 +1,60 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Favorite from '../../models/favorite'; +import serialize from '../../serializers/post'; + +/** + * Get followers of a user + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'sort' parameter + let sort = params.sort || 'desc'; + + // Get favorites + const favorites = await Favorites + .find({ + user_id: user._id + }, {}, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(favorites.map(async favorite => + await serialize(favorite.post) + ))); +}); diff --git a/src/api/endpoints/i/notifications.js b/src/api/endpoints/i/notifications.js new file mode 100644 index 0000000000..a28ceb76a0 --- /dev/null +++ b/src/api/endpoints/i/notifications.js @@ -0,0 +1,120 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Notification from '../../models/notification'; +import serialize from '../../serializers/notification'; +import getFriends from '../../common/get-friends'; + +/** + * Get notifications + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'following' parameter + const following = params.following === 'true'; + + // Get 'mark_as_read' parameter + let markAsRead = params.mark_as_read; + if (markAsRead == null) { + markAsRead = true; + } else { + markAsRead = markAsRead === 'true'; + } + + // Get 'type' parameter + let type = params.type; + if (type !== undefined && type !== null) { + type = type.split(',').map(x => x.trim()); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + const query = { + notifiee_id: user._id + }; + + const sort = { + _id: -1 + }; + + if (following) { + // ID list of the user itself and other users who the user follows + const followingIds = await getFriends(user._id); + + query.notifier_id = { + $in: followingIds + }; + } + + if (type) { + query.type = { + $in: type + }; + } + + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const notifications = await Notification + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(notifications.map(async notification => + await serialize(notification)))); + + // Mark as read all + if (notifications.length > 0 && markAsRead) { + const ids = notifications + .filter(x => x.is_read == false) + .map(x => x._id); + + // Update documents + await Notification.update({ + _id: { $in: ids } + }, { + $set: { is_read: true } + }, { + multi: true + }); + } +}); diff --git a/src/api/endpoints/i/signin_history.js b/src/api/endpoints/i/signin_history.js new file mode 100644 index 0000000000..7def8a41e5 --- /dev/null +++ b/src/api/endpoints/i/signin_history.js @@ -0,0 +1,71 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Signin from '../../models/signin'; +import serialize from '../../serializers/signin'; + +/** + * Get signin history of my account + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + const query = { + user_id: user._id + }; + + const sort = { + _id: -1 + }; + + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const history = await Signin + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(history.map(async record => + await serialize(record)))); +}); diff --git a/src/api/endpoints/i/update.js b/src/api/endpoints/i/update.js new file mode 100644 index 0000000000..a6b68cf01e --- /dev/null +++ b/src/api/endpoints/i/update.js @@ -0,0 +1,95 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; +import event from '../../event'; + +/** + * Update myself + * + * @param {Object} params + * @param {Object} user + * @param {Object} _ + * @param {boolean} isSecure + * @return {Promise<object>} + */ +module.exports = async (params, user, _, isSecure) => + new Promise(async (res, rej) => +{ + // Get 'name' parameter + const name = params.name; + if (name !== undefined && name !== null) { + if (name.length > 50) { + return rej('too long name'); + } + + user.name = name; + } + + // Get 'location' parameter + const location = params.location; + if (location !== undefined && location !== null) { + if (location.length > 50) { + return rej('too long location'); + } + + user.location = location; + } + + // Get 'bio' parameter + const bio = params.bio; + if (bio !== undefined && bio !== null) { + if (bio.length > 500) { + return rej('too long bio'); + } + + user.bio = bio; + } + + // Get 'avatar_id' parameter + const avatar = params.avatar_id; + if (avatar !== undefined && avatar !== null) { + user.avatar_id = new mongo.ObjectID(avatar); + } + + // Get 'banner_id' parameter + const banner = params.banner_id; + if (banner !== undefined && banner !== null) { + user.banner_id = new mongo.ObjectID(banner); + } + + await User.updateOne({ _id: user._id }, { + $set: user + }); + + // Serialize + const iObj = await serialize(user, user, { + detail: true, + includeSecrets: isSecure + }) + + // Send response + res(iObj); + + // Publish i updated event + event(user._id, 'i_updated', iObj); + + // Update search index + if (config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'user', + id: user._id.toString(), + body: { + name: user.name, + bio: user.bio + } + }); + } +}); diff --git a/src/api/endpoints/messaging/history.js b/src/api/endpoints/messaging/history.js new file mode 100644 index 0000000000..dafb38fd1a --- /dev/null +++ b/src/api/endpoints/messaging/history.js @@ -0,0 +1,48 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import History from '../../models/messaging-history'; +import serialize from '../../serializers/messaging-message'; + +/** + * Show messaging history + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get history + const history = await History + .find({ + user_id: user._id + }, {}, { + limit: limit, + sort: { + updated_at: -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(history.map(async h => + await serialize(h.message, user)))); +}); diff --git a/src/api/endpoints/messaging/messages.js b/src/api/endpoints/messaging/messages.js new file mode 100644 index 0000000000..12bd13597a --- /dev/null +++ b/src/api/endpoints/messaging/messages.js @@ -0,0 +1,139 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Message from '../../models/messaging-message'; +import User from '../../models/user'; +import serialize from '../../serializers/messaging-message'; +import publishUserStream from '../../event'; +import { publishMessagingStream } from '../../event'; + +/** + * Get messages + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + let recipient = params.user_id; + if (recipient !== undefined && recipient !== null) { + recipient = await User.findOne({ + _id: new mongo.ObjectID(recipient) + }); + + if (recipient === null) { + return rej('user not found'); + } + } else { + return rej('user_id is required'); + } + + // Get 'mark_as_read' parameter + let markAsRead = params.mark_as_read; + if (markAsRead == null) { + markAsRead = true; + } else { + markAsRead = markAsRead === 'true'; + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + const query = { + $or: [{ + user_id: user._id, + recipient_id: recipient._id + }, { + user_id: recipient._id, + recipient_id: user._id + }] + }; + + const sort = { + created_at: -1 + }; + + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const messages = await Message + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(messages.map(async message => + await serialize(message, user, { + populateRecipient: false + })))); + + if (messages.length === 0) { + return; + } + + // Mark as read all + if (markAsRead) { + const ids = messages + .filter(m => m.is_read == false) + .filter(m => m.recipient_id.equals(user._id)) + .map(m => m._id); + + // Update documents + await Message.update({ + _id: { $in: ids } + }, { + $set: { is_read: true } + }, { + multi: true + }); + + // Publish event + publishMessagingStream(recipient._id, user._id, 'read', ids.map(id => id.toString())); + + const count = await Message + .count({ + recipient_id: user._id, + is_read: false + }); + + if (count == 0) { + // 全ての(いままで未読だった)メッセージを(これで)読みましたよというイベントを発行 + publishUserStream(user._id, 'read_all_messaging_messages'); + } + } +}); diff --git a/src/api/endpoints/messaging/messages/create.js b/src/api/endpoints/messaging/messages/create.js new file mode 100644 index 0000000000..33634a6140 --- /dev/null +++ b/src/api/endpoints/messaging/messages/create.js @@ -0,0 +1,152 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Message from '../../../models/messaging-message'; +import History from '../../../models/messaging-history'; +import User from '../../../models/user'; +import DriveFile from '../../../models/drive-file'; +import serialize from '../../../serializers/messaging-message'; +import publishUserStream from '../../../event'; +import { publishMessagingStream } from '../../../event'; + +/** + * 最大文字数 + */ +const maxTextLength = 500; + +/** + * Create a message + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + let recipient = params.user_id; + if (recipient !== undefined && recipient !== null) { + recipient = await User.findOne({ + _id: new mongo.ObjectID(recipient) + }); + + if (recipient === null) { + return rej('user not found'); + } + } else { + return rej('user_id is required'); + } + + // Get 'text' parameter + let text = params.text; + if (text !== undefined && text !== null) { + text = text.trim(); + if (text.length === 0) { + text = null; + } else if (text.length > maxTextLength) { + return rej('too long text'); + } + } else { + text = null; + } + + // Get 'file_id' parameter + let file = params.file_id; + if (file !== undefined && file !== null) { + file = await DriveFile.findOne({ + _id: new mongo.ObjectID(file), + user_id: user._id + }, { + data: false + }); + + if (file === null) { + return rej('file not found'); + } + } else { + file = null; + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (text === null && file === null) { + return rej('text or file is required'); + } + + // メッセージを作成 + const inserted = await Message.insert({ + created_at: new Date(), + file_id: file ? file._id : undefined, + recipient_id: recipient._id, + text: text ? text : undefined, + user_id: user._id, + is_read: false + }); + + const message = inserted.ops[0]; + + // Serialize + const messageObj = await serialize(message); + + // Reponse + res(messageObj); + + // 自分のストリーム + publishMessagingStream(message.user_id, message.recipient_id, 'message', messageObj); + publishUserStream(message.user_id, 'messaging_message', messageObj); + + // 相手のストリーム + publishMessagingStream(message.recipient_id, message.user_id, 'message', messageObj); + publishUserStream(message.recipient_id, 'messaging_message', messageObj); + + // 5秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する + setTimeout(async () => { + const freshMessage = await Message.findOne({ _id: message._id }, { is_read: true }); + if (!freshMessage.is_read) { + publishUserStream(message.recipient_id, 'unread_messaging_message', messageObj); + } + }, 5000); + + // 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.updateOne({ + user_id: user._id, + partner: recipient._id + }, { + updated_at: new Date(), + user_id: user._id, + partner: recipient._id, + message: message._id + }, { + upsert: true + }); + + // 履歴作成(相手) + History.updateOne({ + user_id: recipient._id, + partner: user._id + }, { + updated_at: new Date(), + user_id: recipient._id, + partner: user._id, + message: message._id + }, { + upsert: true + }); +}); diff --git a/src/api/endpoints/messaging/unread.js b/src/api/endpoints/messaging/unread.js new file mode 100644 index 0000000000..d2de0bc448 --- /dev/null +++ b/src/api/endpoints/messaging/unread.js @@ -0,0 +1,27 @@ +'use strict'; + +/** + * Module dependencies + */ +import Message from '../../models/messaging-message'; + +/** + * Get count of unread messages + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + const count = await Message + .count({ + recipient_id: user._id, + is_read: false + }); + + res({ + count: count + }); +}); diff --git a/src/api/endpoints/meta.js b/src/api/endpoints/meta.js new file mode 100644 index 0000000000..7938cb91b4 --- /dev/null +++ b/src/api/endpoints/meta.js @@ -0,0 +1,24 @@ +'use strict'; + +/** + * Module dependencies + */ +import Git from 'nodegit'; + +/** + * Show core info + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + const repository = await Git.Repository.open(__dirname + '/../../'); + + res({ + maintainer: config.maintainer, + commit: (await repository.getHeadCommit()).sha(), + secure: config.https.enable + }); +}); diff --git a/src/api/endpoints/my/apps.js b/src/api/endpoints/my/apps.js new file mode 100644 index 0000000000..d23bc38b1c --- /dev/null +++ b/src/api/endpoints/my/apps.js @@ -0,0 +1,59 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import App from '../../models/app'; +import serialize from '../../serializers/app'; + +/** + * Get my apps + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + const query = { + user_id: user._id + }; + + // Execute query + const apps = await App + .find(query, {}, { + limit: limit, + skip: offset, + sort: { + created_at: -1 + } + }) + .toArray(); + + // Reply + res(await Promise.all(apps.map(async app => + await serialize(app)))); +}); diff --git a/src/api/endpoints/notifications/mark_as_read.js b/src/api/endpoints/notifications/mark_as_read.js new file mode 100644 index 0000000000..16eb2009ac --- /dev/null +++ b/src/api/endpoints/notifications/mark_as_read.js @@ -0,0 +1,54 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Notification from '../../../models/notification'; +import serialize from '../../../serializers/notification'; +import event from '../../../event'; + +/** + * Mark as read a notification + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + const notificationId = params.notification; + + if (notificationId === undefined || notificationId === null) { + return rej('notification is required'); + } + + // Get notifcation + const notification = await Notification + .findOne({ + _id: new mongo.ObjectID(notificationId), + i: user._id + }); + + if (notification === null) { + return rej('notification-not-found'); + } + + // Update + notification.is_read = true; + Notification.updateOne({ _id: notification._id }, { + $set: { + is_read: true + } + }); + + // Response + res(); + + // Serialize + const notificationObj = await serialize(notification); + + // Publish read_notification event + event(user._id, 'read_notification', notificationObj); +}); diff --git a/src/api/endpoints/posts.js b/src/api/endpoints/posts.js new file mode 100644 index 0000000000..05fc871ec1 --- /dev/null +++ b/src/api/endpoints/posts.js @@ -0,0 +1,65 @@ +'use strict'; + +/** + * Module dependencies + */ +import Post from '../models/post'; +import serialize from '../serializers/post'; + +/** + * Lists all posts + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = {}; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const posts = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(posts.map(async post => await serialize(post)))); +}); diff --git a/src/api/endpoints/posts/context.js b/src/api/endpoints/posts/context.js new file mode 100644 index 0000000000..5f040b8505 --- /dev/null +++ b/src/api/endpoints/posts/context.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a context of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found', 'POST_NOT_FOUND'); + } + + const context = []; + let i = 0; + + async function get(id) { + i++; + const p = await Post.findOne({ _id: id }); + + if (i > offset) { + context.push(p); + } + + if (context.length == limit) { + return; + } + + if (p.reply_to_id) { + await get(p.reply_to_id); + } + } + + if (post.reply_to_id) { + await get(post.reply_to_id); + } + + // Serialize + res(await Promise.all(context.map(async post => + await serialize(post, user)))); +}); diff --git a/src/api/endpoints/posts/create.js b/src/api/endpoints/posts/create.js new file mode 100644 index 0000000000..cdcbf4f966 --- /dev/null +++ b/src/api/endpoints/posts/create.js @@ -0,0 +1,345 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import parse from '../../../common/text'; +import Post from '../../models/post'; +import User from '../../models/user'; +import Following from '../../models/following'; +import DriveFile from '../../models/drive-file'; +import serialize from '../../serializers/post'; +import createFile from '../../common/add-file-to-drive'; +import notify from '../../common/notify'; +import event from '../../event'; + +/** + * 最大文字数 + */ +const maxTextLength = 300; + +/** + * 添付できるファイルの数 + */ +const maxMediaCount = 4; + +/** + * Create a post + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @return {Promise<object>} + */ +module.exports = (params, user, app) => + new Promise(async (res, rej) => +{ + // Get 'text' parameter + let text = params.text; + if (text !== undefined && text !== null) { + text = text.trim(); + if (text.length == 0) { + text = null; + } else if (text.length > maxTextLength) { + return rej('too long text'); + } + } else { + text = null; + } + + // Get 'media_ids' parameter + let media = params.media_ids; + let files = []; + if (media !== undefined && media !== null) { + media = media.split(','); + + if (media.length > maxMediaCount) { + return rej('too many media'); + } + + // Drop duplicates + media = media.filter((x, i, s) => s.indexOf(x) == i); + + // Fetch files + // forEach だと途中でエラーなどがあっても return できないので + // 敢えて for を使っています。 + for (let i = 0; i < media.length; i++) { + const image = media[i]; + + // Fetch file + // SELECT _id + const entity = await DriveFile.findOne({ + _id: new mongo.ObjectID(image), + user_id: user._id + }, { + _id: true + }); + + if (entity === null) { + return rej('file not found'); + } else { + files.push(entity); + } + } + } else { + files = null; + } + + // Get 'repost_id' parameter + let repost = params.repost_id; + if (repost !== undefined && repost !== null) { + // Fetch repost to post + repost = await Post.findOne({ + _id: new mongo.ObjectID(repost) + }); + + if (repost == null) { + return rej('repostee is not found'); + } else if (repost.repost_id && !repost.text && !repost.media_ids) { + return rej('cannot repost to repost'); + } + + // Fetch recently post + const latestPost = await Post.findOne({ + user_id: user._id + }, {}, { + sort: { + _id: -1 + } + }); + + // 直近と同じRepost対象かつ引用じゃなかったらエラー + if (latestPost && + latestPost.repost_id && + latestPost.repost_id.equals(repost._id) && + text === null && files === null) { + return rej('二重Repostです(NEED TRANSLATE)'); + } + + // 直近がRepost対象かつ引用じゃなかったらエラー + if (latestPost && + latestPost._id.equals(repost._id) && + text === null && files === null) { + return rej('二重Repostです(NEED TRANSLATE)'); + } + } else { + repost = null; + } + + // Get 'reply_to_id' parameter + let replyTo = params.reply_to_id; + if (replyTo !== undefined && replyTo !== null) { + replyTo = await Post.findOne({ + _id: new mongo.ObjectID(replyTo) + }); + + if (replyTo === null) { + return rej('reply to post is not found'); + } + + // 返信対象が引用でないRepostだったらエラー + if (replyTo.repost_id && !replyTo.text && !replyTo.media_ids) { + return rej('cannot reply to repost'); + } + } else { + replyTo = null; + } + + // テキストが無いかつ添付ファイルが無いかつRepostも無かったらエラー + if (text === null && files === null && repost === null) { + return rej('text, media_ids or repost_id is required'); + } + + // 投稿を作成 + const inserted = await Post.insert({ + created_at: new Date(), + media_ids: media ? files.map(file => file._id) : undefined, + reply_to_id: replyTo ? replyTo._id : undefined, + repost_id: repost ? repost._id : undefined, + text: text, + user_id: user._id, + app_id: app ? app._id : null + }); + + const post = inserted.ops[0]; + + // Serialize + const postObj = await serialize(post); + + // Reponse + res(postObj); + + //-------------------------------- + // Post processes + + let mentions = []; + + function addMention(mentionee, type) { + // Reject if already added + if (mentions.some(x => x.equals(mentionee))) return; + + // Add mention + mentions.push(mentionee); + + // Publish event + if (!user._id.equals(mentionee)) { + event(mentionee, type, postObj); + } + } + + // Publish event to myself's stream + event(user._id, 'post', postObj); + + // Fetch all followers + const followers = await Following + .find({ + followee_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + follower_id: true, + _id: false + }) + .toArray(); + + // Publish event to followers stream + followers.forEach(following => + event(following.follower_id, 'post', postObj)); + + // Increment my posts count + User.updateOne({ _id: user._id }, { + $inc: { + posts_count: 1 + } + }); + + // If has in reply to post + if (replyTo) { + // Increment replies count + Post.updateOne({ _id: replyTo._id }, { + $inc: { + replies_count: 1 + } + }); + + // 自分自身へのリプライでない限りは通知を作成 + notify(replyTo.user_id, user._id, 'reply', { + post_id: post._id + }); + + // Add mention + addMention(replyTo.user_id, 'reply'); + } + + // If it is repost + if (repost) { + // Notify + const type = text ? 'quote' : 'repost'; + notify(repost.user_id, user._id, type, { + post_id: post._id + }); + + // If it is quote repost + if (text) { + // Add mention + addMention(repost.user_id, 'quote'); + } else { + // Publish event + if (!user._id.equals(repost.user_id)) { + event(repost.user_id, 'repost', postObj); + } + } + + // 今までで同じ投稿をRepostしているか + const existRepost = await Post.findOne({ + user_id: user._id, + repost_id: repost._id, + _id: { + $ne: post._id + } + }); + + if (!existRepost) { + // Update repostee status + Post.updateOne({ _id: repost._id }, { + $inc: { + repost_count: 1 + } + }); + } + } + + // If has text content + if (text) { + // Analyze + const tokens = parse(text); + + // Extract a hashtags + const hashtags = tokens + .filter(t => t.type == 'hashtag') + .map(t => t.hashtag) + // Drop dupulicates + .filter((v, i, s) => s.indexOf(v) == i); + + // ハッシュタグをデータベースに登録 + //registerHashtags(user, hashtags); + + // Extract an '@' mentions + const atMentions = tokens + .filter(t => t.type == 'mention') + .map(m => m.username) + // Drop dupulicates + .filter((v, i, s) => s.indexOf(v) == i); + + // Resolve all mentions + await Promise.all(atMentions.map(async (mention) => { + // Fetch mentioned user + // SELECT _id + const mentionee = await User + .findOne({ + username_lower: mention.toLowerCase() + }, { _id: true }); + + // When mentioned user not found + if (mentionee == null) return; + + // 既に言及されたユーザーに対する返信や引用repostの場合も無視 + if (replyTo && replyTo.user_id.equals(mentionee._id)) return; + if (repost && repost.user_id.equals(mentionee._id)) return; + + // Add mention + addMention(mentionee._id, 'mention'); + + // Create notification + notify(mentionee._id, user._id, 'mention', { + post_id: post._id + }); + + return; + })); + } + + // Register to search database + if (text && config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'post', + id: post._id.toString(), + body: { + text: post.text + } + }); + } + + // Append mentions data + if (mentions.length > 0) { + Post.updateOne({ _id: post._id }, { + $set: { + mentions: mentions + } + }); + } +}); diff --git a/src/api/endpoints/posts/favorites/create.js b/src/api/endpoints/posts/favorites/create.js new file mode 100644 index 0000000000..d20a523d5d --- /dev/null +++ b/src/api/endpoints/posts/favorites/create.js @@ -0,0 +1,56 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Favorite from '../../models/favorite'; +import Post from '../../models/post'; + +/** + * Favorite a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + let postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get favoritee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Check arleady favorited + const exist = await Favorite.findOne({ + post_id: post._id, + user_id: user._id + }); + + if (exist !== null) { + return rej('already favorited'); + } + + // Create favorite + const inserted = await Favorite.insert({ + created_at: new Date(), + post_id: post._id, + user_id: user._id + }); + + const favorite = inserted.ops[0]; + + // Send response + res(); +}); diff --git a/src/api/endpoints/posts/favorites/delete.js b/src/api/endpoints/posts/favorites/delete.js new file mode 100644 index 0000000000..e250d1772c --- /dev/null +++ b/src/api/endpoints/posts/favorites/delete.js @@ -0,0 +1,52 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Favorite from '../../models/favorite'; +import Post from '../../models/post'; + +/** + * Unfavorite a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + let postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get favoritee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Check arleady favorited + const exist = await Favorite.findOne({ + post_id: post._id, + user_id: user._id + }); + + if (exist === null) { + return rej('already not favorited'); + } + + // Delete favorite + await Favorite.deleteOne({ + _id: exist._id + }); + + // Send response + res(); +}); diff --git a/src/api/endpoints/posts/likes.js b/src/api/endpoints/posts/likes.js new file mode 100644 index 0000000000..4778189fc6 --- /dev/null +++ b/src/api/endpoints/posts/likes.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import Like from '../../models/like'; +import serialize from '../../serializers/user'; + +/** + * Show a likes of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'sort' parameter + let sort = params.sort || 'desc'; + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Issue query + const likes = await Like + .find({ + post_id: post._id, + deleted_at: { $exists: false } + }, {}, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(likes.map(async like => + await serialize(like.user_id, user)))); +}); diff --git a/src/api/endpoints/posts/likes/create.js b/src/api/endpoints/posts/likes/create.js new file mode 100644 index 0000000000..eb35c1e4b0 --- /dev/null +++ b/src/api/endpoints/posts/likes/create.js @@ -0,0 +1,93 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Like from '../../../models/like'; +import Post from '../../../models/post'; +import User from '../../../models/user'; +import notify from '../../../common/notify'; +import event from '../../../event'; +import serializeUser from '../../../serializers/user'; +import serializePost from '../../../serializers/post'; + +/** + * Like a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + let postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get likee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Myself + if (post.user_id.equals(user._id)) { + return rej('-need-translate-'); + } + + // Check arleady liked + const exist = await Like.findOne({ + post_id: post._id, + user_id: user._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already liked'); + } + + // Create like + const inserted = await Like.insert({ + created_at: new Date(), + post_id: post._id, + user_id: user._id + }); + + const like = inserted.ops[0]; + + // Send response + res(); + + // Increment likes count + Post.updateOne({ _id: post._id }, { + $inc: { + likes_count: 1 + } + }); + + // Increment user likes count + User.updateOne({ _id: user._id }, { + $inc: { + likes_count: 1 + } + }); + + // Increment user liked count + User.updateOne({ _id: post.user_id }, { + $inc: { + liked_count: 1 + } + }); + + // Notify + notify(post.user_id, user._id, 'like', { + post_id: post._id + }); +}); diff --git a/src/api/endpoints/posts/likes/delete.js b/src/api/endpoints/posts/likes/delete.js new file mode 100644 index 0000000000..b60df63af5 --- /dev/null +++ b/src/api/endpoints/posts/likes/delete.js @@ -0,0 +1,80 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Like from '../../../models/like'; +import Post from '../../../models/post'; +import User from '../../../models/user'; +// import event from '../../../event'; + +/** + * Unlike a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + let postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get likee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Check arleady liked + const exist = await Like.findOne({ + post_id: post._id, + user_id: user._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not liked'); + } + + // Delete like + await Like.updateOne({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); + + // Decrement likes count + Post.updateOne({ _id: post._id }, { + $inc: { + likes_count: -1 + } + }); + + // Decrement user likes count + User.updateOne({ _id: user._id }, { + $inc: { + likes_count: -1 + } + }); + + // Decrement user liked count + User.updateOne({ _id: post.user_id }, { + $inc: { + liked_count: -1 + } + }); +}); diff --git a/src/api/endpoints/posts/mentions.js b/src/api/endpoints/posts/mentions.js new file mode 100644 index 0000000000..6358e1f4a9 --- /dev/null +++ b/src/api/endpoints/posts/mentions.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import getFriends from '../../common/get-friends'; +import serialize from '../../serializers/post'; + +/** + * Get mentions of myself + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'following' parameter + const following = params.following === 'true'; + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Construct query + const query = { + mentions: user._id + }; + + const sort = { + _id: -1 + }; + + if (following) { + const followingIds = await getFriends(user._id); + + query.user_id = { + $in: followingIds + }; + } + + if (since) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const mentions = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(mentions.map(async mention => + await serialize(mention, user) + ))); +}); diff --git a/src/api/endpoints/posts/replies.js b/src/api/endpoints/posts/replies.js new file mode 100644 index 0000000000..5eab6f896f --- /dev/null +++ b/src/api/endpoints/posts/replies.js @@ -0,0 +1,73 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a replies of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'sort' parameter + let sort = params.sort || 'desc'; + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found', 'POST_NOT_FOUND'); + } + + // Issue query + const replies = await Post + .find({ reply_to_id: post._id }, {}, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(replies.map(async post => + await serialize(post, user)))); +}); diff --git a/src/api/endpoints/posts/reposts.js b/src/api/endpoints/posts/reposts.js new file mode 100644 index 0000000000..8b418a682f --- /dev/null +++ b/src/api/endpoints/posts/reposts.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a reposts of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found', 'POST_NOT_FOUND'); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = { + repost_id: post._id + }; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const reposts = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(reposts.map(async post => + await serialize(post, user)))); +}); diff --git a/src/api/endpoints/posts/search.js b/src/api/endpoints/posts/search.js new file mode 100644 index 0000000000..0f214ef7ae --- /dev/null +++ b/src/api/endpoints/posts/search.js @@ -0,0 +1,138 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; +const escapeRegexp = require('escape-regexp'); + +/** + * Search a post + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'query' parameter + let query = params.query; + if (query === undefined || query === null || query.trim() === '') { + return rej('query is required'); + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'max' parameter + let max = params.max; + if (max !== undefined && max !== null) { + max = parseInt(max, 10); + + // From 1 to 30 + if (!(1 <= max && max <= 30)) { + return rej('invalid max range'); + } + } else { + max = 10; + } + + // If Elasticsearch is available, search by it + // 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 posts + const posts = await Post + .find({ + text: new RegExp(escapedQuery) + }, { + sort: { + _id: -1 + }, + limit: max, + skip: offset + }) + .toArray(); + + // Serialize + res(await Promise.all(posts.map(async post => + await serialize(post, me)))); +} + +// Search by Elasticsearch +async function byElasticsearch(res, rej, me, query, offset, max) { + const es = require('../../db/elasticsearch'); + + es.search({ + index: 'misskey', + type: 'post', + body: { + size: max, + from: offset, + query: { + simple_query_string: { + fields: ['text'], + query: query, + default_operator: 'and' + } + }, + sort: [ + { _doc: 'desc' } + ], + highlight: { + pre_tags: ['<mark>'], + post_tags: ['</mark>'], + encoder: 'html', + fields: { + text: {} + } + } + } + }, 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)); + + // Fetxh found posts + const posts = await Post + .find({ + _id: { + $in: hits + } + }, {}, { + sort: { + _id: -1 + } + }) + .toArray(); + + posts.map(post => { + post._highlight = response.hits.hits.filter(hit => post._id.equals(hit._id))[0].highlight.text[0]; + }); + + // Serialize + res(await Promise.all(posts.map(async post => + await serialize(post, me)))); + }); +} diff --git a/src/api/endpoints/posts/show.js b/src/api/endpoints/posts/show.js new file mode 100644 index 0000000000..19cdb74251 --- /dev/null +++ b/src/api/endpoints/posts/show.js @@ -0,0 +1,40 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise<object>} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Serialize + res(await serialize(post, user, { + serializeReplyTo: true, + includeIsLiked: true + })); +}); diff --git a/src/api/endpoints/posts/timeline.js b/src/api/endpoints/posts/timeline.js new file mode 100644 index 0000000000..489542da71 --- /dev/null +++ b/src/api/endpoints/posts/timeline.js @@ -0,0 +1,78 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import getFriends from '../../common/get-friends'; +import serialize from '../../serializers/post'; + +/** + * Get timeline of myself + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @return {Promise<object>} + */ +module.exports = (params, user, app) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // ID list of the user itself and other users who the user follows + const followingIds = await getFriends(user._id); + + // Construct query + const sort = { + _id: -1 + }; + const query = { + user_id: { + $in: followingIds + } + }; + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const timeline = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(timeline.map(async post => + await serialize(post, user) + ))); +}); diff --git a/src/api/endpoints/username/available.js b/src/api/endpoints/username/available.js new file mode 100644 index 0000000000..a93637bc1f --- /dev/null +++ b/src/api/endpoints/username/available.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Module dependencies + */ +import User from '../../models/user'; +import { validateUsername } from '../../models/user'; + +/** + * Check available username + * + * @param {Object} params + * @return {Promise<object>} + */ +module.exports = async (params) => + new Promise(async (res, rej) => +{ + // Get 'username' parameter + const username = params.username; + if (username == null || username == '') { + return rej('username-is-required'); + } + + // Validate username + if (!validateUsername(username)) { + return rej('invalid-username'); + } + + // Get exist + const exist = await User + .count({ + username_lower: username.toLowerCase() + }, { + limit: 1 + }); + + // Reply + res({ + available: exist === 0 + }); +}); diff --git a/src/api/endpoints/users.js b/src/api/endpoints/users.js new file mode 100644 index 0000000000..cd40cdf4e1 --- /dev/null +++ b/src/api/endpoints/users.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Module dependencies + */ +import User from '../models/user'; +import serialize from '../serializers/user'; + +/** + * Lists all users + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = {}; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const users = await User + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me)))); +}); diff --git a/src/api/endpoints/users/followers.js b/src/api/endpoints/users/followers.js new file mode 100644 index 0000000000..303f55e450 --- /dev/null +++ b/src/api/endpoints/users/followers.js @@ -0,0 +1,102 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import Following from '../../models/following'; +import serialize from '../../serializers/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get followers of a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Get 'iknow' parameter + const iknow = params.iknow === 'true'; + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'cursor' parameter + const cursor = params.cursor || null; + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const query = { + followee_id: user._id, + deleted_at: { $exists: false } + }; + + // ログインしていてかつ iknow フラグがあるとき + if (me && iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.follower_id = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: new mongo.ObjectID(cursor) + }; + } + + // Get followers + const following = await Following + .find(query, {}, { + limit: limit + 1, + sort: { _id: -1 } + }) + .toArray(); + + // 「次のページ」があるかどうか + const inStock = following.length === limit + 1; + if (inStock) { + following.pop(); + } + + // Serialize + const users = await Promise.all(following.map(async f => + await serialize(f.follower_id, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? following[following.length - 1]._id : null, + }); +}); diff --git a/src/api/endpoints/users/following.js b/src/api/endpoints/users/following.js new file mode 100644 index 0000000000..ec3954563a --- /dev/null +++ b/src/api/endpoints/users/following.js @@ -0,0 +1,102 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import Following from '../../models/following'; +import serialize from '../../serializers/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get following users of a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Get 'iknow' parameter + const iknow = params.iknow === 'true'; + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'cursor' parameter + const cursor = params.cursor || null; + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const query = { + follower_id: user._id, + deleted_at: { $exists: false } + }; + + // ログインしていてかつ iknow フラグがあるとき + if (me && iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.followee_id = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: new mongo.ObjectID(cursor) + }; + } + + // Get followers + const following = await Following + .find(query, {}, { + limit: limit + 1, + sort: { _id: -1 } + }) + .toArray(); + + // 「次のページ」があるかどうか + const inStock = following.length === limit + 1; + if (inStock) { + following.pop(); + } + + // Serialize + const users = await Promise.all(following.map(async f => + await serialize(f.followee_id, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? following[following.length - 1]._id : null, + }); +}); diff --git a/src/api/endpoints/users/posts.js b/src/api/endpoints/users/posts.js new file mode 100644 index 0000000000..6d6f8a6904 --- /dev/null +++ b/src/api/endpoints/users/posts.js @@ -0,0 +1,114 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import User from '../../models/user'; +import serialize from '../../serializers/post'; + +/** + * Get posts of a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Get 'with_replies' parameter + let withReplies = params.with_replies; + if (withReplies !== undefined && withReplies !== null && withReplies === 'true') { + withReplies = true; + } else { + withReplies = false; + } + + // Get 'with_media' parameter + let withMedia = params.with_media; + if (withMedia !== undefined && withMedia !== null && withMedia === 'true') { + withMedia = true; + } else { + withMedia = false; + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = { + user_id: user._id + }; + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + if (!withReplies) { + query.reply_to_id = null; + } + + if (withMedia) { + query.media_ids = { + $exists: true, + $ne: null + }; + } + + // Issue query + const posts = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(posts.map(async (post) => + await serialize(post, me) + ))); +}); diff --git a/src/api/endpoints/users/recommendation.js b/src/api/endpoints/users/recommendation.js new file mode 100644 index 0000000000..9daab0ec57 --- /dev/null +++ b/src/api/endpoints/users/recommendation.js @@ -0,0 +1,61 @@ +'use strict'; + +/** + * Module dependencies + */ +import User from '../../models/user'; +import serialize from '../../serializers/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get recommended users + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // ID list of the user itself and other users who the user follows + const followingIds = await getFriends(me._id); + + const users = await User + .find({ + _id: { + $nin: followingIds + } + }, {}, { + limit: limit, + skip: offset, + sort: { + followers_count: -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me, { detail: true })))); +}); diff --git a/src/api/endpoints/users/search.js b/src/api/endpoints/users/search.js new file mode 100644 index 0000000000..3a3fe677db --- /dev/null +++ b/src/api/endpoints/users/search.js @@ -0,0 +1,116 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; +const escapeRegexp = require('escape-regexp'); + +/** + * Search a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'query' parameter + let query = params.query; + if (query === undefined || query === null || query.trim() === '') { + return rej('query is required'); + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'max' parameter + let max = params.max; + if (max !== undefined && max !== null) { + max = parseInt(max, 10); + + // From 1 to 30 + if (!(1 <= max && max <= 30)) { + return rej('invalid max range'); + } + } else { + max = 10; + } + + // If Elasticsearch is available, search by it + // 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: [{ + username_lower: new RegExp(escapedQuery.toLowerCase()) + }, { + name: new RegExp(escapedQuery) + }] + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(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 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me, { detail: true })))); + }); +} diff --git a/src/api/endpoints/users/search_by_username.js b/src/api/endpoints/users/search_by_username.js new file mode 100644 index 0000000000..9e3efbd85c --- /dev/null +++ b/src/api/endpoints/users/search_by_username.js @@ -0,0 +1,65 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; + +/** + * Search a user by username + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'query' parameter + let query = params.query; + if (query === undefined || query === null || query.trim() === '') { + return rej('query is required'); + } + + query = query.trim(); + + if (!/^[a-zA-Z0-9-]+$/.test(query)) { + return rej('invalid query'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + const users = await User + .find({ + username_lower: new RegExp(query.toLowerCase()) + }, { + limit: limit, + skip: offset + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me, { detail: true })))); +}); diff --git a/src/api/endpoints/users/show.js b/src/api/endpoints/users/show.js new file mode 100644 index 0000000000..af475c6cb9 --- /dev/null +++ b/src/api/endpoints/users/show.js @@ -0,0 +1,49 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; + +/** + * Show a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise<object>} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + let userId = params.user_id; + if (userId === undefined || userId === null || userId === '') { + userId = null; + } + + // Get 'username' parameter + let username = params.username; + if (username === undefined || username === null || username === '') { + username = null; + } + + if (userId === null && username === null) { + return rej('user_id or username is required'); + } + + // Lookup user + const user = userId !== null + ? await User.findOne({ _id: new mongo.ObjectID(userId) }) + : await User.findOne({ username_lower: username.toLowerCase() }); + + if (user === null) { + return rej('user not found'); + } + + // Send response + res(await serialize(user, me, { + detail: true + })); +}); diff --git a/src/api/event.ts b/src/api/event.ts new file mode 100644 index 0000000000..584fc8e86c --- /dev/null +++ b/src/api/event.ts @@ -0,0 +1,36 @@ +import * as mongo from 'mongodb'; +import * as redis from 'redis'; + +type ID = string | mongo.ObjectID; + +class MisskeyEvent { + private redisClient: redis.RedisClient; + + constructor() { + // Connect to Redis + this.redisClient = redis.createClient( + config.redis.port, config.redis.host); + } + + private publish(channel: string, type: string, value?: Object): void { + const message = value == null ? + { type: type } : + { type: type, body: value }; + + this.redisClient.publish(`misskey:${channel}`, JSON.stringify(message)); + } + + public publishUserStream(userId: ID, type: string, value?: Object): void { + this.publish(`user-stream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishMessagingStream(userId: ID, otherpartyId: ID, type: string, value?: Object): void { + this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); + } +} + +const ev = new MisskeyEvent(); + +export default ev.publishUserStream.bind(ev); + +export const publishMessagingStream = ev.publishMessagingStream.bind(ev); diff --git a/src/api/limitter.ts b/src/api/limitter.ts new file mode 100644 index 0000000000..9cc25675d8 --- /dev/null +++ b/src/api/limitter.ts @@ -0,0 +1,69 @@ +import * as Limiter from 'ratelimiter'; +import limiterDB from '../db/redis'; +import { IEndpoint } from './endpoints'; +import { IAuthContext } from './authenticate'; + +export default (endpoint: IEndpoint, ctx: IAuthContext) => new Promise((ok, reject) => { + const limitKey = endpoint.hasOwnProperty('limitKey') + ? endpoint.limitKey + : endpoint.name; + + const hasMinInterval = + endpoint.hasOwnProperty('minInterval'); + + const hasRateLimit = + endpoint.hasOwnProperty('limitDuration') && + endpoint.hasOwnProperty('limitMax'); + + if (hasMinInterval) { + min(); + } else if (hasRateLimit) { + max(); + } else { + ok(); + } + + // Short-term limit + function min(): void { + const minIntervalLimiter = new Limiter({ + id: `${ctx.user._id}:${limitKey}:min`, + duration: endpoint.minInterval, + max: 1, + db: limiterDB + }); + + minIntervalLimiter.get((limitErr, limit) => { + if (limitErr) { + reject('ERR'); + } else if (limit.remaining === 0) { + reject('BRIEF_REQUEST_INTERVAL'); + } else { + if (hasRateLimit) { + max(); + } else { + ok(); + } + } + }); + } + + // Long term limit + function max(): void { + const limiter = new Limiter({ + id: `${ctx.user._id}:${limitKey}`, + duration: endpoint.limitDuration, + max: endpoint.limitMax, + db: limiterDB + }); + + limiter.get((limitErr, limit) => { + if (limitErr) { + reject('ERR'); + } else if (limit.remaining === 0) { + reject('RATE_LIMIT_EXCEEDED'); + } else { + ok(); + } + }); + } +}); diff --git a/src/api/models/app.ts b/src/api/models/app.ts new file mode 100644 index 0000000000..221a53906a --- /dev/null +++ b/src/api/models/app.ts @@ -0,0 +1,7 @@ +const collection = global.db.collection('apps'); + +collection.createIndex('name_id'); +collection.createIndex('name_id_lower'); +collection.createIndex('secret'); + +export default collection; diff --git a/src/api/models/appdata.ts b/src/api/models/appdata.ts new file mode 100644 index 0000000000..2d471c4347 --- /dev/null +++ b/src/api/models/appdata.ts @@ -0,0 +1 @@ +export default global.db.collection('appdata'); diff --git a/src/api/models/auth-session.ts b/src/api/models/auth-session.ts new file mode 100644 index 0000000000..6dbe2fa70e --- /dev/null +++ b/src/api/models/auth-session.ts @@ -0,0 +1 @@ +export default global.db.collection('auth_sessions'); diff --git a/src/api/models/drive-file.ts b/src/api/models/drive-file.ts new file mode 100644 index 0000000000..06ebf02005 --- /dev/null +++ b/src/api/models/drive-file.ts @@ -0,0 +1,11 @@ +export default global.db.collection('drive_files'); + +export function validateFileName(name: string): boolean { + return ( + (name.trim().length > 0) && + (name.length <= 200) && + (name.indexOf('\\') === -1) && + (name.indexOf('/') === -1) && + (name.indexOf('..') === -1) + ); +} diff --git a/src/api/models/drive-folder.ts b/src/api/models/drive-folder.ts new file mode 100644 index 0000000000..f345b3c340 --- /dev/null +++ b/src/api/models/drive-folder.ts @@ -0,0 +1,8 @@ +export default global.db.collection('drive_folders'); + +export function isValidFolderName(name: string): boolean { + return ( + (name.trim().length > 0) && + (name.length <= 200) + ); +} diff --git a/src/api/models/drive-tag.ts b/src/api/models/drive-tag.ts new file mode 100644 index 0000000000..83c0a8f681 --- /dev/null +++ b/src/api/models/drive-tag.ts @@ -0,0 +1 @@ +export default global.db.collection('drive_tags'); diff --git a/src/api/models/favorite.ts b/src/api/models/favorite.ts new file mode 100644 index 0000000000..6d9e7c72b3 --- /dev/null +++ b/src/api/models/favorite.ts @@ -0,0 +1 @@ +export default global.db.collection('favorites'); diff --git a/src/api/models/following.ts b/src/api/models/following.ts new file mode 100644 index 0000000000..f9d8a41c5e --- /dev/null +++ b/src/api/models/following.ts @@ -0,0 +1 @@ +export default global.db.collection('following'); diff --git a/src/api/models/like.ts b/src/api/models/like.ts new file mode 100644 index 0000000000..aa3bd75c1c --- /dev/null +++ b/src/api/models/like.ts @@ -0,0 +1 @@ +export default global.db.collection('likes'); diff --git a/src/api/models/messaging-history.ts b/src/api/models/messaging-history.ts new file mode 100644 index 0000000000..3505e94b57 --- /dev/null +++ b/src/api/models/messaging-history.ts @@ -0,0 +1 @@ +export default global.db.collection('messaging_histories'); diff --git a/src/api/models/messaging-message.ts b/src/api/models/messaging-message.ts new file mode 100644 index 0000000000..0e900bda58 --- /dev/null +++ b/src/api/models/messaging-message.ts @@ -0,0 +1 @@ +export default global.db.collection('messaging_messages'); diff --git a/src/api/models/notification.ts b/src/api/models/notification.ts new file mode 100644 index 0000000000..1cb7b80838 --- /dev/null +++ b/src/api/models/notification.ts @@ -0,0 +1 @@ +export default global.db.collection('notifications'); diff --git a/src/api/models/post.ts b/src/api/models/post.ts new file mode 100644 index 0000000000..bea92a5f61 --- /dev/null +++ b/src/api/models/post.ts @@ -0,0 +1 @@ +export default global.db.collection('posts'); diff --git a/src/api/models/signin.ts b/src/api/models/signin.ts new file mode 100644 index 0000000000..896afaaf84 --- /dev/null +++ b/src/api/models/signin.ts @@ -0,0 +1 @@ +export default global.db.collection('signin'); diff --git a/src/api/models/user.ts b/src/api/models/user.ts new file mode 100644 index 0000000000..1742f5cafb --- /dev/null +++ b/src/api/models/user.ts @@ -0,0 +1,10 @@ +const collection = global.db.collection('users'); + +collection.createIndex('username'); +collection.createIndex('token'); + +export default collection; + +export function validateUsername(username: string): boolean { + return /^[a-zA-Z0-9\-]{3,20}$/.test(username); +} diff --git a/src/api/models/userkey.ts b/src/api/models/userkey.ts new file mode 100644 index 0000000000..204f283a28 --- /dev/null +++ b/src/api/models/userkey.ts @@ -0,0 +1,5 @@ +const collection = global.db.collection('userkeys'); + +collection.createIndex('key'); + +export default collection; diff --git a/src/api/private/signin.ts b/src/api/private/signin.ts new file mode 100644 index 0000000000..b68fc89aa0 --- /dev/null +++ b/src/api/private/signin.ts @@ -0,0 +1,57 @@ +import * as express from 'express'; +import * as bcrypt from 'bcrypt'; +import User from '../models/user'; +import Signin from '../models/signin'; +import serialize from '../serializers/signin'; +import event from '../event'; + + +export default async (req: express.Request, res: express.Response) => { + res.header('Access-Control-Allow-Credentials', 'true'); + + const username = req.body['username']; + const password = req.body['password']; + + // Fetch user + const user = await User.findOne({ + username_lower: username.toLowerCase() + }); + + if (user === null) { + res.status(404).send('user not found'); + return; + } + + // Compare password + const same = await bcrypt.compare(password, user.password); + + if (same) { + const expires = 1000 * 60 * 60 * 24 * 365; // One Year + res.cookie('i', user.token, { + path: '/', + domain: `.${config.host}`, + secure: config.url.substr(0, 5) === 'https', + httpOnly: false, + expires: new Date(Date.now() + expires), + maxAge: expires + }); + + res.sendStatus(204); + } else { + res.status(400).send('incorrect password'); + } + + // Append signin history + const inserted = await Signin.insert({ + created_at: new Date(), + user_id: user._id, + ip: req.ip, + headers: req.headers, + success: same + }); + + const record = inserted.ops[0]; + + // Publish signin event + event(user._id, 'signin', await serialize(record)); +}; diff --git a/src/api/private/signup.ts b/src/api/private/signup.ts new file mode 100644 index 0000000000..7df1f25b37 --- /dev/null +++ b/src/api/private/signup.ts @@ -0,0 +1,94 @@ +import * as express from 'express'; +import * as bcrypt from 'bcrypt'; +import rndstr from 'rndstr'; +const recaptcha = require('recaptcha-promise'); +import User from '../models/user'; +import { validateUsername } from '../models/user'; +import serialize from '../serializers/user'; + + +recaptcha.init({ + secret_key: config.recaptcha.secretKey +}); + +export default async (req: express.Request, res: express.Response) => { + // Verify recaptcha + const success = await recaptcha(req.body['g-recaptcha-response']); + + if (!success) { + res.status(400).send('recaptcha-failed'); + return; + } + + const username = req.body['username']; + const password = req.body['password']; + const name = '名無し'; + + // Validate username + if (!validateUsername(username)) { + res.sendStatus(400); + return; + } + + // Fetch exist user that same username + const usernameExist = await User + .count({ + username_lower: username.toLowerCase() + }, { + limit: 1 + }); + + // Check username already used + if (usernameExist !== 0) { + res.sendStatus(400); + return; + } + + // Generate hash of password + const salt = bcrypt.genSaltSync(14); + const hash = bcrypt.hashSync(password, salt); + + // Generate secret + const secret = rndstr('a-zA-Z0-9', 32); + + // Create account + const inserted = await User.insert({ + token: secret, + avatar_id: null, + banner_id: null, + birthday: null, + created_at: new Date(), + bio: null, + email: null, + followers_count: 0, + following_count: 0, + links: null, + location: null, + name: name, + password: hash, + posts_count: 0, + likes_count: 0, + liked_count: 0, + drive_capacity: 1073741824, // 1GB + username: username, + username_lower: username.toLowerCase() + }); + + const account = inserted.ops[0]; + + // Response + res.send(await serialize(account)); + + // Create search index + if (config.elasticsearch.enable) { + const es = require('../../db/elasticsearch'); + es.index({ + index: 'misskey', + type: 'user', + id: account._id.toString(), + body: { + username: username + } + }); + } +}; diff --git a/src/api/reply.ts b/src/api/reply.ts new file mode 100644 index 0000000000..e47fc85b9b --- /dev/null +++ b/src/api/reply.ts @@ -0,0 +1,13 @@ +import * as express from 'express'; + +export default (res: express.Response, x?: any, y?: any) => { + if (x === undefined) { + res.sendStatus(204); + } else if (typeof x === 'number') { + res.status(x).send({ + error: x === 500 ? 'INTERNAL_ERROR' : y + }); + } else { + res.send(x); + } +}; diff --git a/src/api/serializers/app.ts b/src/api/serializers/app.ts new file mode 100644 index 0000000000..23a12c977d --- /dev/null +++ b/src/api/serializers/app.ts @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +const deepcopy = require('deepcopy'); +import App from '../models/app'; +import User from '../models/user'; +import Userkey from '../models/userkey'; + +/** + * Serialize an app + * + * @param {Object} app + * @param {Object} me? + * @param {Object} options? + * @return {Promise<Object>} + */ +export default ( + app: any, + me?: any, + options?: { + includeSecret: boolean, + includeProfileImageIds: boolean + } +) => new Promise<any>(async (resolve, reject) => { + const opts = options || { + includeSecret: false, + includeProfileImageIds: false + }; + + let _app: any; + + // Populate the app if 'app' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(app)) { + _app = await App.findOne({ + _id: app + }); + } else if (typeof app === 'string') { + _app = await User.findOne({ + _id: new mongo.ObjectID(app) + }); + } else { + _app = deepcopy(app); + } + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + // Rename _id to id + _app.id = _app._id; + delete _app._id; + + delete _app.name_id_lower; + + // Visible by only owner + if (!opts.includeSecret) { + delete _app.secret; + } + + _app.icon_url = _app.icon != null + ? `${config.drive_url}/${_app.icon}` + : `${config.drive_url}/app-default.jpg`; + + if (me) { + // 既に連携しているか + const exist = await Userkey.count({ + app_id: _app.id, + user_id: me, + }, { + limit: 1 + }); + + _app.is_authorized = exist === 1; + } + + resolve(_app); +}); diff --git a/src/api/serializers/auth-session.ts b/src/api/serializers/auth-session.ts new file mode 100644 index 0000000000..786684b4e0 --- /dev/null +++ b/src/api/serializers/auth-session.ts @@ -0,0 +1,42 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +const deepcopy = require('deepcopy'); +import serializeApp from './app'; + +/** + * Serialize an auth session + * + * @param {Object} session + * @param {Object} me? + * @return {Promise<Object>} + */ +export default ( + session: any, + me?: any +) => new Promise<any>(async (resolve, reject) => { + let _session: any; + + // TODO: Populate session if it ID + + _session = deepcopy(session); + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + delete _session._id; + + // Populate app + _session.app = await serializeApp(_session.app_id, me); + + resolve(_session); +}); diff --git a/src/api/serializers/drive-file.ts b/src/api/serializers/drive-file.ts new file mode 100644 index 0000000000..635cf13867 --- /dev/null +++ b/src/api/serializers/drive-file.ts @@ -0,0 +1,63 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../models/drive-file'; +import serializeDriveTag from './drive-tag'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a drive file + * + * @param {Object} file + * @param {Object} options? + * @return {Promise<Object>} + */ +export default ( + file: any, + options?: { + includeTags: boolean + } +) => new Promise<Object>(async (resolve, reject) => { + const opts = options || { + includeTags: true + }; + + let _file: any; + + // Populate the file if 'file' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(file)) { + _file = await DriveFile.findOne({ + _id: file + }, { + data: false + }); + } else if (typeof file === 'string') { + _file = await DriveFile.findOne({ + _id: new mongo.ObjectID(file) + }, { + data: false + }); + } else { + _file = deepcopy(file); + } + + // Rename _id to id + _file.id = _file._id; + delete _file._id; + + delete _file.data; + + _file.url = `${config.drive_url}/${_file.id}/${encodeURIComponent(_file.name)}`; + + if (opts.includeTags && _file.tags) { + // Populate tags + _file.tags = await _file.tags.map(async (tag: any) => + await serializeDriveTag(tag) + ); + } + + resolve(_file); +}); diff --git a/src/api/serializers/drive-folder.ts b/src/api/serializers/drive-folder.ts new file mode 100644 index 0000000000..ee5a973e14 --- /dev/null +++ b/src/api/serializers/drive-folder.ts @@ -0,0 +1,52 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../models/drive-folder'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a drive folder + * + * @param {Object} folder + * @param {Object} options? + * @return {Promise<Object>} + */ +const self = ( + folder: any, + options?: { + includeParent: boolean + } +) => new Promise<Object>(async (resolve, reject) => { + const opts = options || { + includeParent: false + }; + + let _folder: any; + + // Populate the folder if 'folder' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(folder)) { + _folder = await DriveFolder.findOne({_id: folder}); + } else if (typeof folder === 'string') { + _folder = await DriveFolder.findOne({_id: new mongo.ObjectID(folder)}); + } else { + _folder = deepcopy(folder); + } + + // Rename _id to id + _folder.id = _folder._id; + delete _folder._id; + + if (opts.includeParent && _folder.parent_id) { + // Populate parent folder + _folder.parent = await self(_folder.parent_id, { + includeParent: true + }); + } + + resolve(_folder); +}); + +export default self; diff --git a/src/api/serializers/drive-tag.ts b/src/api/serializers/drive-tag.ts new file mode 100644 index 0000000000..182e9a66d4 --- /dev/null +++ b/src/api/serializers/drive-tag.ts @@ -0,0 +1,37 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveTag from '../models/drive-tag'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a drive tag + * + * @param {Object} tag + * @return {Promise<Object>} + */ +const self = ( + tag: any +) => new Promise<Object>(async (resolve, reject) => { + let _tag: any; + + // Populate the tag if 'tag' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(tag)) { + _tag = await DriveTag.findOne({_id: tag}); + } else if (typeof tag === 'string') { + _tag = await DriveTag.findOne({_id: new mongo.ObjectID(tag)}); + } else { + _tag = deepcopy(tag); + } + + // Rename _id to id + _tag.id = _tag._id; + delete _tag._id; + + resolve(_tag); +}); + +export default self; diff --git a/src/api/serializers/messaging-message.ts b/src/api/serializers/messaging-message.ts new file mode 100644 index 0000000000..0855b25d16 --- /dev/null +++ b/src/api/serializers/messaging-message.ts @@ -0,0 +1,64 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Message from '../models/messaging-message'; +import serializeUser from './user'; +import serializeDriveFile from './drive-file'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a message + * + * @param {Object} message + * @param {Object} me? + * @param {Object} options? + * @return {Promise<Object>} + */ +export default ( + message: any, + me: any, + options?: { + populateRecipient: boolean + } +) => new Promise<Object>(async (resolve, reject) => { + const opts = options || { + populateRecipient: true + }; + + let _message: any; + + // Populate the message if 'message' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(message)) { + _message = await Message.findOne({ + _id: message + }); + } else if (typeof message === 'string') { + _message = await Message.findOne({ + _id: new mongo.ObjectID(message) + }); + } else { + _message = deepcopy(message); + } + + // Rename _id to id + _message.id = _message._id; + delete _message._id; + + // Populate user + _message.user = await serializeUser(_message.user_id, me); + + if (_message.file) { + // Populate file + _message.file = await serializeDriveFile(_message.file_id); + } + + if (opts.populateRecipient) { + // Populate recipient + _message.recipient = await serializeUser(_message.recipient_id, me); + } + + resolve(_message); +}); diff --git a/src/api/serializers/notification.ts b/src/api/serializers/notification.ts new file mode 100644 index 0000000000..56769f50d0 --- /dev/null +++ b/src/api/serializers/notification.ts @@ -0,0 +1,66 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Notification from '../models/notification'; +import serializeUser from './user'; +import serializePost from './post'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a notification + * + * @param {Object} notification + * @return {Promise<Object>} + */ +export default (notification: any) => new Promise<Object>(async (resolve, reject) => { + let _notification: any; + + // Populate the notification if 'notification' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(notification)) { + _notification = await Notification.findOne({ + _id: notification + }); + } else if (typeof notification === 'string') { + _notification = await Notification.findOne({ + _id: new mongo.ObjectID(notification) + }); + } else { + _notification = deepcopy(notification); + } + + // Rename _id to id + _notification.id = _notification._id; + delete _notification._id; + + // Rename notifier_id to user_id + _notification.user_id = _notification.notifier_id; + delete _notification.notifier_id; + + const me = _notification.notifiee_id; + delete _notification.notifiee_id; + + // Populate notifier + _notification.user = await serializeUser(_notification.user_id, me); + + switch (_notification.type) { + case 'follow': + // nope + break; + case 'mention': + case 'reply': + case 'repost': + case 'quote': + case 'like': + // Populate post + _notification.post = await serializePost(_notification.post_id, me); + break; + default: + console.error(`Unknown type: ${_notification.type}`); + break; + } + + resolve(_notification); +}); diff --git a/src/api/serializers/post.ts b/src/api/serializers/post.ts new file mode 100644 index 0000000000..a17aa9035b --- /dev/null +++ b/src/api/serializers/post.ts @@ -0,0 +1,103 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../models/post'; +import Like from '../models/like'; +import serializeUser from './user'; +import serializeDriveFile from './drive-file'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a post + * + * @param {Object} post + * @param {Object} me? + * @param {Object} options? + * @return {Promise<Object>} + */ +const self = ( + post: any, + me?: any, + options?: { + serializeReplyTo: boolean, + serializeRepost: boolean, + includeIsLiked: boolean + } +) => new Promise<Object>(async (resolve, reject) => { + const opts = options || { + serializeReplyTo: true, + serializeRepost: true, + includeIsLiked: true + }; + + let _post: any; + + // Populate the post if 'post' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(post)) { + _post = await Post.findOne({ + _id: post + }); + } else if (typeof post === 'string') { + _post = await Post.findOne({ + _id: new mongo.ObjectID(post) + }); + } else { + _post = deepcopy(post); + } + + const id = _post._id; + + // Rename _id to id + _post.id = _post._id; + delete _post._id; + + delete _post.mentions; + + // Populate user + _post.user = await serializeUser(_post.user_id, me); + + if (_post.media_ids) { + // Populate media + _post.media = await Promise.all(_post.media_ids.map(async fileId => + await serializeDriveFile(fileId) + )); + } + + if (_post.reply_to_id && opts.serializeReplyTo) { + // Populate reply to post + _post.reply_to = await self(_post.reply_to_id, me, { + serializeReplyTo: false, + serializeRepost: false, + includeIsLiked: false + }); + } + + if (_post.repost_id && opts.serializeRepost) { + // Populate repost + _post.repost = await self(_post.repost_id, me, { + serializeReplyTo: _post.text == null, + serializeRepost: _post.text == null, + includeIsLiked: _post.text == null + }); + } + + // Check if it is liked + if (me && opts.includeIsLiked) { + const liked = await Like + .count({ + user_id: me._id, + post_id: id + }, { + limit: 1 + }); + + _post.is_liked = liked === 1; + } + + resolve(_post); +}); + +export default self; diff --git a/src/api/serializers/signin.ts b/src/api/serializers/signin.ts new file mode 100644 index 0000000000..d6d7a39471 --- /dev/null +++ b/src/api/serializers/signin.ts @@ -0,0 +1,25 @@ +'use strict'; + +/** + * Module dependencies + */ +const deepcopy = require('deepcopy'); + +/** + * Serialize a signin record + * + * @param {Object} record + * @return {Promise<Object>} + */ +export default ( + record: any +) => new Promise<Object>(async (resolve, reject) => { + + const _record = deepcopy(record); + + // Rename _id to id + _record.id = _record._id; + delete _record._id; + + resolve(_record); +}); diff --git a/src/api/serializers/user.ts b/src/api/serializers/user.ts new file mode 100644 index 0000000000..0585863950 --- /dev/null +++ b/src/api/serializers/user.ts @@ -0,0 +1,138 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +const deepcopy = require('deepcopy'); +import User from '../models/user'; +import Following from '../models/following'; +import getFriends from '../common/get-friends'; + +/** + * Serialize a user + * + * @param {Object} user + * @param {Object} me? + * @param {Object} options? + * @return {Promise<Object>} + */ +export default ( + user: any, + me?: any, + options?: { + detail: boolean, + includeSecrets: boolean + } +) => new Promise<any>(async (resolve, reject) => { + + const opts = Object.assign({ + detail: false, + includeSecrets: false + }, options); + + let _user: any; + + // Populate the user if 'user' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(user)) { + _user = await User.findOne({ + _id: user + }); + } else if (typeof user === 'string') { + _user = await User.findOne({ + _id: new mongo.ObjectID(user) + }); + } else { + _user = deepcopy(user); + } + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + // Rename _id to id + _user.id = _user._id; + delete _user._id; + + // Remove private properties + delete _user.password; + delete _user.token; + delete _user.username_lower; + + // Visible via only the official client + if (!opts.includeSecrets) { + delete _user.data; + delete _user.email; + } + + _user.avatar_url = _user.avatar_id != null + ? `${config.drive_url}/${_user.avatar_id}` + : `${config.drive_url}/default-avatar.jpg`; + + _user.banner_url = _user.banner_id != null + ? `${config.drive_url}/${_user.banner_id}` + : null; + + if (!me || !me.equals(_user.id) || !opts.detail) { + delete _user.avatar_id; + delete _user.banner_id; + + delete _user.drive_capacity; + } + + if (me && !me.equals(_user.id)) { + // If the user is following + const follow = await Following.findOne({ + follower_id: me, + followee_id: _user.id, + deleted_at: { $exists: false } + }); + _user.is_following = follow !== null; + + // If the user is followed + const follow2 = await Following.findOne({ + follower_id: _user.id, + followee_id: me, + deleted_at: { $exists: false } + }); + _user.is_followed = follow2 !== null; + } + + if (me && !me.equals(_user.id) && opts.detail) { + const myFollowingIds = await getFriends(me); + + // Get following you know count + const followingYouKnowCount = await Following.count({ + followee_id: { $in: myFollowingIds }, + follower_id: _user.id, + deleted_at: { $exists: false } + }); + _user.following_you_know_count = followingYouKnowCount; + + // Get followers you know count + const followersYouKnowCount = await Following.count({ + followee_id: _user.id, + follower_id: { $in: myFollowingIds }, + deleted_at: { $exists: false } + }); + _user.followers_you_know_count = followersYouKnowCount; + } + + resolve(_user); +}); +/* +function img(url) { + return { + thumbnail: { + large: `${url}`, + medium: '', + small: '' + } + }; +} +*/ diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000000..78b0d0aea8 --- /dev/null +++ b/src/api/server.ts @@ -0,0 +1,52 @@ +/** + * API Server + */ + +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as cors from 'cors'; +import * as multer from 'multer'; + +import authenticate from './authenticate'; +import endpoints from './endpoints'; + +/** + * Init app + */ +const app = express(); + +app.disable('x-powered-by'); +app.set('etag', false); +app.use(bodyParser.urlencoded({ extended: true })); +app.use(cors({ + origin: true +})); + +/** + * Authetication + */ +/*app.post('*', async (req, res, next) => { + try { + ctx = await authenticate(req); + next(); + } catch (e) { + res.status(403).send('AUTHENTICATION_FAILED'); + } +}); +*/ +/** + * Register endpoint handlers + */ +endpoints.forEach(endpoint => + endpoint.withFile ? + app.post('/' + endpoint.name, + endpoint.withFile ? multer({ dest: 'uploads/' }).single('file') : null, + require('./api-handler').default.bind(null, endpoint)) : + app.post('/' + endpoint.name, + require('./api-handler').default.bind(null, endpoint)) +); + +app.post('/signup', require('./private/signup').default); +app.post('/signin', require('./private/signin').default); + +module.exports = app; diff --git a/src/api/stream/home.ts b/src/api/stream/home.ts new file mode 100644 index 0000000000..975bea4c60 --- /dev/null +++ b/src/api/stream/home.ts @@ -0,0 +1,10 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; + +export default function homeStream(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + // Subscribe Home stream channel + subscriber.subscribe(`misskey:user-stream:${user._id}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/api/stream/messaging.ts b/src/api/stream/messaging.ts new file mode 100644 index 0000000000..4ec139b82b --- /dev/null +++ b/src/api/stream/messaging.ts @@ -0,0 +1,60 @@ +import * as mongodb from 'mongodb'; +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import Message from '../models/messaging-message'; +import { publishMessagingStream } from '../event'; + +export default function messagingStream(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + const otherparty = request.resourceURL.query.otherparty; + + // Subscribe messaging stream + subscriber.subscribe(`misskey:messaging-stream:${user._id}-${otherparty}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); + + connection.on('message', async (data) => { + const msg = JSON.parse(data.utf8Data); + + switch (msg.type) { + case 'read': + if (!msg.id) { + return; + } + + const id = new mongodb.ObjectID(msg.id); + + // Fetch message + // SELECT _id, user_id, is_read + const message = await Message.findOne({ + _id: id, + recipient_id: user._id + }, { + fields: { + _id: true, + user_id: true, + is_read: true + } + }); + + if (message == null) { + return; + } + + if (message.is_read) { + return; + } + + // Update documents + await Message.update({ + _id: id + }, { + $set: { is_read: true } + }); + + // Publish event + publishMessagingStream(message.user_id, user._id, 'read', id.toString()); + break; + } + }); +} diff --git a/src/api/streaming.ts b/src/api/streaming.ts new file mode 100644 index 0000000000..38068d1e3d --- /dev/null +++ b/src/api/streaming.ts @@ -0,0 +1,69 @@ +import * as http from 'http'; +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import User from './models/user'; + +import homeStream from './stream/home'; +import messagingStream from './stream/messaging'; + +module.exports = (server: http.Server) => { + /** + * Init websocket server + */ + const ws = new websocket.server({ + httpServer: server + }); + + ws.on('request', async (request) => { + const connection = request.accept(); + + const user = await authenticate(connection); + + // Connect to Redis + const subscriber = redis.createClient( + config.redis.port, config.redis.host); + + connection.on('close', () => { + subscriber.unsubscribe(); + subscriber.quit(); + }); + + const channel = + request.resourceURL.pathname === '/' ? homeStream : + request.resourceURL.pathname === '/messaging' ? messagingStream : + null; + + if (channel !== null) { + channel(request, connection, subscriber, user); + } else { + connection.close(); + } + }); +}; + +function authenticate(connection: websocket.connection): Promise<any> { + return new Promise((resolve, reject) => { + // Listen first message + connection.once('message', async (data) => { + const msg = JSON.parse(data.utf8Data); + + // Fetch user + // SELECT _id + const user = await User + .findOne({ + token: msg.i + }, { + _id: true + }); + + if (user === null) { + connection.close(); + return; + } + + connection.send('authenticated'); + + resolve(user); + }); + }); +} |