diff options
| author | tamaina <tamaina@hotmail.co.jp> | 2018-04-11 20:59:30 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2018-04-11 20:59:30 +0900 |
| commit | 185805363d244c43e9e91c67fd94dbb7473c283a (patch) | |
| tree | 838914e262c0fca5737588a7bba64e2b9f3d8e5f /src/server | |
| parent | v2380 (diff) | |
| parent | Merge pull request #1 from syuilo/master (diff) | |
| download | misskey-185805363d244c43e9e91c67fd94dbb7473c283a.tar.gz misskey-185805363d244c43e9e91c67fd94dbb7473c283a.tar.bz2 misskey-185805363d244c43e9e91c67fd94dbb7473c283a.zip | |
Merge pull request #2 from tamaina/master
追従
Diffstat (limited to 'src/server')
142 files changed, 9924 insertions, 0 deletions
diff --git a/src/server/activitypub/inbox.ts b/src/server/activitypub/inbox.ts new file mode 100644 index 0000000000..643d2945bd --- /dev/null +++ b/src/server/activitypub/inbox.ts @@ -0,0 +1,32 @@ +import * as bodyParser from 'body-parser'; +import * as express from 'express'; +import { parseRequest } from 'http-signature'; +import { createHttp } from '../../queue'; + +const app = express.Router(); + +app.post('/users/:user/inbox', bodyParser.json({ + type() { + return true; + } +}), async (req, res) => { + let signature; + + req.headers.authorization = 'Signature ' + req.headers.signature; + + try { + signature = parseRequest(req); + } catch (exception) { + return res.sendStatus(401); + } + + createHttp({ + type: 'processInbox', + activity: req.body, + signature, + }).save(); + + return res.status(202).end(); +}); + +export default app; diff --git a/src/server/activitypub/index.ts b/src/server/activitypub/index.ts new file mode 100644 index 0000000000..042579db9d --- /dev/null +++ b/src/server/activitypub/index.ts @@ -0,0 +1,18 @@ +import * as express from 'express'; + +import user from './user'; +import inbox from './inbox'; +import outbox from './outbox'; +import publicKey from './publickey'; +import note from './note'; + +const app = express(); +app.disable('x-powered-by'); + +app.use(user); +app.use(inbox); +app.use(outbox); +app.use(publicKey); +app.use(note); + +export default app; diff --git a/src/server/activitypub/note.ts b/src/server/activitypub/note.ts new file mode 100644 index 0000000000..1c2e695b80 --- /dev/null +++ b/src/server/activitypub/note.ts @@ -0,0 +1,28 @@ +import * as express from 'express'; +import context from '../../remote/activitypub/renderer/context'; +import render from '../../remote/activitypub/renderer/note'; +import Note from '../../models/note'; + +const app = express.Router(); + +app.get('/notes/:note', async (req, res, next) => { + const accepted = req.accepts(['html', 'application/activity+json', 'application/ld+json']); + if (!(['application/activity+json', 'application/ld+json'] as any[]).includes(accepted)) { + return next(); + } + + const note = await Note.findOne({ + _id: req.params.note + }); + + if (note === null) { + return res.sendStatus(404); + } + + const rendered = await render(note); + rendered['@context'] = context; + + res.json(rendered); +}); + +export default app; diff --git a/src/server/activitypub/outbox.ts b/src/server/activitypub/outbox.ts new file mode 100644 index 0000000000..1c97c17a2e --- /dev/null +++ b/src/server/activitypub/outbox.ts @@ -0,0 +1,28 @@ +import * as express from 'express'; +import context from '../../remote/activitypub/renderer/context'; +import renderNote from '../../remote/activitypub/renderer/note'; +import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-collection'; +import config from '../../config'; +import Note from '../../models/note'; +import User from '../../models/user'; + +const app = express.Router(); + +app.get('/users/:user/outbox', async (req, res) => { + const userId = req.params.user; + + const user = await User.findOne({ _id: userId }); + + const notes = await Note.find({ userId: user._id }, { + limit: 20, + sort: { _id: -1 } + }); + + const renderedNotes = await Promise.all(notes.map(note => renderNote(note))); + const rendered = renderOrderedCollection(`${config.url}/users/${userId}/inbox`, user.notesCount, renderedNotes); + rendered['@context'] = context; + + res.json(rendered); +}); + +export default app; diff --git a/src/server/activitypub/publickey.ts b/src/server/activitypub/publickey.ts new file mode 100644 index 0000000000..e874b82729 --- /dev/null +++ b/src/server/activitypub/publickey.ts @@ -0,0 +1,23 @@ +import * as express from 'express'; +import context from '../../remote/activitypub/renderer/context'; +import render from '../../remote/activitypub/renderer/key'; +import User, { isLocalUser } from '../../models/user'; + +const app = express.Router(); + +app.get('/users/:user/publickey', async (req, res) => { + const userId = req.params.user; + + const user = await User.findOne({ _id: userId }); + + if (isLocalUser(user)) { + const rendered = render(user); + rendered['@context'] = context; + + res.json(rendered); + } else { + res.sendStatus(400); + } +}); + +export default app; diff --git a/src/server/activitypub/user.ts b/src/server/activitypub/user.ts new file mode 100644 index 0000000000..9e98e92b6a --- /dev/null +++ b/src/server/activitypub/user.ts @@ -0,0 +1,19 @@ +import * as express from 'express'; +import context from '../../remote/activitypub/renderer/context'; +import render from '../../remote/activitypub/renderer/person'; +import User from '../../models/user'; + +const app = express.Router(); + +app.get('/users/:user', async (req, res) => { + const userId = req.params.user; + + const user = await User.findOne({ _id: userId }); + + const rendered = render(user); + rendered['@context'] = context; + + res.json(rendered); +}); + +export default app; diff --git a/src/server/api/api-handler.ts b/src/server/api/api-handler.ts new file mode 100644 index 0000000000..409069b6a0 --- /dev/null +++ b/src/server/api/api-handler.ts @@ -0,0 +1,34 @@ +import * as express from 'express'; + +import { Endpoint } from './endpoints'; +import authenticate from './authenticate'; +import call from './call'; +import { IUser } from '../../models/user'; +import { IApp } from '../../models/app'; + +export default async (endpoint: Endpoint, req: express.Request, res: express.Response) => { + const reply = (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); + } + }; + + let user: IUser; + let app: IApp; + + // Authentication + try { + [user, app] = await authenticate(req.body['i']); + } catch (e) { + return reply(403, 'AUTHENTICATION_FAILED'); + } + + // API invoking + call(endpoint, user, app, req.body, req).then(reply).catch(e => reply(400, e)); +}; diff --git a/src/server/api/authenticate.ts b/src/server/api/authenticate.ts new file mode 100644 index 0000000000..836fb7cfe8 --- /dev/null +++ b/src/server/api/authenticate.ts @@ -0,0 +1,39 @@ +import App, { IApp } from '../../models/app'; +import { default as User, IUser } from '../../models/user'; +import AccessToken from '../../models/access-token'; +import isNativeToken from './common/is-native-token'; + +export default (token: string) => new Promise<[IUser, IApp]>(async (resolve, reject) => { + if (token == null) { + resolve([null, null]); + return; + } + + if (isNativeToken(token)) { + // Fetch user + const user: IUser = await User + .findOne({ token }); + + if (user === null) { + return reject('user not found'); + } + + resolve([user, null]); + } else { + const accessToken = await AccessToken.findOne({ + hash: token.toLowerCase() + }); + + if (accessToken === null) { + return reject('invalid signature'); + } + + const app = await App + .findOne({ _id: accessToken.appId }); + + const user = await User + .findOne({ _id: accessToken.userId }); + + resolve([user, app]); + } +}); diff --git a/src/server/api/bot/core.ts b/src/server/api/bot/core.ts new file mode 100644 index 0000000000..d41af48057 --- /dev/null +++ b/src/server/api/bot/core.ts @@ -0,0 +1,439 @@ +import * as EventEmitter from 'events'; +import * as bcrypt from 'bcryptjs'; + +import User, { IUser, init as initUser, ILocalUser } from '../../../models/user'; + +import getNoteSummary from '../../../renderers/get-note-summary'; +import getUserName from '../../../renderers/get-user-name'; +import getUserSummary from '../../../renderers/get-user-summary'; +import parseAcct from '../../../acct/parse'; +import getNotificationSummary from '../../../renderers/get-notification-summary'; + +const hmm = [ + '?', + 'ふぅ~む...?', + 'ちょっと何言ってるかわからないです', + '「ヘルプ」と言うと利用可能な操作が確認できますよ' +]; + +/** + * Botの頭脳 + */ +export default class BotCore extends EventEmitter { + public user: IUser = null; + + private context: Context = null; + + constructor(user?: IUser) { + super(); + + this.user = user; + } + + public clearContext() { + this.setContext(null); + } + + public setContext(context: Context) { + this.context = context; + this.emit('updated'); + + if (context) { + context.on('updated', () => { + this.emit('updated'); + }); + } + } + + public export() { + return { + user: this.user, + context: this.context ? this.context.export() : null + }; + } + + protected _import(data) { + this.user = data.user ? initUser(data.user) : null; + this.setContext(data.context ? Context.import(this, data.context) : null); + } + + public static import(data) { + const bot = new BotCore(); + bot._import(data); + return bot; + } + + public async q(query: string): Promise<string> { + if (this.context != null) { + return await this.context.q(query); + } + + if (/^@[a-zA-Z0-9_]+$/.test(query)) { + return await this.showUserCommand(query); + } + + switch (query) { + case 'ping': + return 'PONG'; + + case 'help': + case 'ヘルプ': + return '利用可能なコマンド一覧です:\n' + + 'help: これです\n' + + 'me: アカウント情報を見ます\n' + + 'login, signin: サインインします\n' + + 'logout, signout: サインアウトします\n' + + 'note: 投稿します\n' + + 'tl: タイムラインを見ます\n' + + 'no: 通知を見ます\n' + + '@<ユーザー名>: ユーザーを表示します\n' + + '\n' + + 'タイムラインや通知を見た後、「次」というとさらに遡ることができます。'; + + case 'me': + return this.user ? `${getUserName(this.user)}としてサインインしています。\n\n${getUserSummary(this.user)}` : 'サインインしていません'; + + case 'login': + case 'signin': + case 'ログイン': + case 'サインイン': + if (this.user != null) return '既にサインインしていますよ!'; + this.setContext(new SigninContext(this)); + return await this.context.greet(); + + case 'logout': + case 'signout': + case 'ログアウト': + case 'サインアウト': + if (this.user == null) return '今はサインインしてないですよ!'; + this.signout(); + return 'ご利用ありがとうございました <3'; + + case 'note': + case '投稿': + if (this.user == null) return 'まずサインインしてください。'; + this.setContext(new NoteContext(this)); + return await this.context.greet(); + + case 'tl': + case 'タイムライン': + if (this.user == null) return 'まずサインインしてください。'; + this.setContext(new TlContext(this)); + return await this.context.greet(); + + case 'no': + case 'notifications': + case '通知': + if (this.user == null) return 'まずサインインしてください。'; + this.setContext(new NotificationsContext(this)); + return await this.context.greet(); + + case 'guessing-game': + case '数当てゲーム': + this.setContext(new GuessingGameContext(this)); + return await this.context.greet(); + + default: + return hmm[Math.floor(Math.random() * hmm.length)]; + } + } + + public signin(user: IUser) { + this.user = user; + this.emit('signin', user); + this.emit('updated'); + } + + public signout() { + const user = this.user; + this.user = null; + this.emit('signout', user); + this.emit('updated'); + } + + public async refreshUser() { + this.user = await User.findOne({ + _id: this.user._id + }, { + fields: { + data: false + } + }); + + this.emit('updated'); + } + + public async showUserCommand(q: string): Promise<string> { + try { + const user = await require('../endpoints/users/show')(parseAcct(q.substr(1)), this.user); + + const text = getUserSummary(user); + + return text; + } catch (e) { + return `問題が発生したようです...: ${e}`; + } + } +} + +abstract class Context extends EventEmitter { + protected bot: BotCore; + + public abstract async greet(): Promise<string>; + public abstract async q(query: string): Promise<string>; + public abstract export(): any; + + constructor(bot: BotCore) { + super(); + this.bot = bot; + } + + public static import(bot: BotCore, data: any) { + if (data.type == 'guessing-game') return GuessingGameContext.import(bot, data.content); + if (data.type == 'note') return NoteContext.import(bot, data.content); + if (data.type == 'tl') return TlContext.import(bot, data.content); + if (data.type == 'notifications') return NotificationsContext.import(bot, data.content); + if (data.type == 'signin') return SigninContext.import(bot, data.content); + return null; + } +} + +class SigninContext extends Context { + private temporaryUser: ILocalUser = null; + + public async greet(): Promise<string> { + return 'まずユーザー名を教えてください:'; + } + + public async q(query: string): Promise<string> { + if (this.temporaryUser == null) { + // Fetch user + const user = await User.findOne({ + usernameLower: query.toLowerCase(), + host: null + }, { + fields: { + data: false + } + }) as ILocalUser; + + if (user === null) { + return `${query}というユーザーは存在しませんでした... もう一度教えてください:`; + } else { + this.temporaryUser = user; + this.emit('updated'); + return `パスワードを教えてください:`; + } + } else { + // Compare password + const same = await bcrypt.compare(query, this.temporaryUser.password); + + if (same) { + this.bot.signin(this.temporaryUser); + this.bot.clearContext(); + return `${getUserName(this.temporaryUser)}さん、おかえりなさい!`; + } else { + return `パスワードが違います... もう一度教えてください:`; + } + } + } + + public export() { + return { + type: 'signin', + content: { + temporaryUser: this.temporaryUser + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new SigninContext(bot); + context.temporaryUser = data.temporaryUser; + return context; + } +} + +class NoteContext extends Context { + public async greet(): Promise<string> { + return '内容:'; + } + + public async q(query: string): Promise<string> { + await require('../endpoints/notes/create')({ + text: query + }, this.bot.user); + this.bot.clearContext(); + return '投稿しましたよ!'; + } + + public export() { + return { + type: 'note' + }; + } + + public static import(bot: BotCore, data: any) { + const context = new NoteContext(bot); + return context; + } +} + +class TlContext extends Context { + private next: string = null; + + public async greet(): Promise<string> { + return await this.getTl(); + } + + public async q(query: string): Promise<string> { + if (query == '次') { + return await this.getTl(); + } else { + this.bot.clearContext(); + return await this.bot.q(query); + } + } + + private async getTl() { + const tl = await require('../endpoints/notes/timeline')({ + limit: 5, + untilId: this.next ? this.next : undefined + }, this.bot.user); + + if (tl.length > 0) { + this.next = tl[tl.length - 1].id; + this.emit('updated'); + + const text = tl + .map(note => `${getUserName(note.user)}\n「${getNoteSummary(note)}」`) + .join('\n-----\n'); + + return text; + } else { + return 'タイムラインに表示するものがありません...'; + } + } + + public export() { + return { + type: 'tl', + content: { + next: this.next, + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new TlContext(bot); + context.next = data.next; + return context; + } +} + +class NotificationsContext extends Context { + private next: string = null; + + public async greet(): Promise<string> { + return await this.getNotifications(); + } + + public async q(query: string): Promise<string> { + if (query == '次') { + return await this.getNotifications(); + } else { + this.bot.clearContext(); + return await this.bot.q(query); + } + } + + private async getNotifications() { + const notifications = await require('../endpoints/i/notifications')({ + limit: 5, + untilId: this.next ? this.next : undefined + }, this.bot.user); + + if (notifications.length > 0) { + this.next = notifications[notifications.length - 1].id; + this.emit('updated'); + + const text = notifications + .map(notification => getNotificationSummary(notification)) + .join('\n-----\n'); + + return text; + } else { + return '通知はありません'; + } + } + + public export() { + return { + type: 'notifications', + content: { + next: this.next, + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new NotificationsContext(bot); + context.next = data.next; + return context; + } +} + +class GuessingGameContext extends Context { + private secret: number; + private history: number[] = []; + + public async greet(): Promise<string> { + this.secret = Math.floor(Math.random() * 100); + this.emit('updated'); + return '0~100の秘密の数を当ててみてください:'; + } + + public async q(query: string): Promise<string> { + if (query == 'やめる') { + this.bot.clearContext(); + return 'やめました。'; + } + + const guess = parseInt(query, 10); + + if (isNaN(guess)) { + return '整数で推測してください。「やめる」と言うとゲームをやめます。'; + } + + const firsttime = this.history.indexOf(guess) === -1; + + this.history.push(guess); + this.emit('updated'); + + if (this.secret < guess) { + return firsttime ? `${guess}よりも小さいですね` : `もう一度言いますが${guess}より小さいですよ`; + } else if (this.secret > guess) { + return firsttime ? `${guess}よりも大きいですね` : `もう一度言いますが${guess}より大きいですよ`; + } else { + this.bot.clearContext(); + return `正解です🎉 (${this.history.length}回目で当てました)`; + } + } + + public export() { + return { + type: 'guessing-game', + content: { + secret: this.secret, + history: this.history + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new GuessingGameContext(bot); + context.secret = data.secret; + context.history = data.history; + return context; + } +} diff --git a/src/server/api/bot/interfaces/line.ts b/src/server/api/bot/interfaces/line.ts new file mode 100644 index 0000000000..be3bfe33d3 --- /dev/null +++ b/src/server/api/bot/interfaces/line.ts @@ -0,0 +1,239 @@ +import * as EventEmitter from 'events'; +import * as express from 'express'; +import * as request from 'request'; +import * as crypto from 'crypto'; +import User from '../../../../models/user'; +import config from '../../../../config'; +import BotCore from '../core'; +import _redis from '../../../../db/redis'; +import prominence = require('prominence'); +import getAcct from '../../../../acct/render'; +import parseAcct from '../../../../acct/parse'; +import getNoteSummary from '../../../../renderers/get-note-summary'; +import getUserName from '../../../../renderers/get-user-name'; + +const redis = prominence(_redis); + +// SEE: https://developers.line.me/media/messaging-api/messages/sticker_list.pdf +const stickers = [ + '297', + '298', + '299', + '300', + '301', + '302', + '303', + '304', + '305', + '306', + '307' +]; + +class LineBot extends BotCore { + private replyToken: string; + + private reply(messages: any[]) { + request.post({ + url: 'https://api.line.me/v2/bot/message/reply', + headers: { + 'Authorization': `Bearer ${config.line_bot.channel_access_token}` + }, + json: { + replyToken: this.replyToken, + messages: messages + } + }, (err, res, body) => { + if (err) { + console.error(err); + return; + } + }); + } + + public async react(ev: any): Promise<void> { + this.replyToken = ev.replyToken; + + switch (ev.type) { + // メッセージ + case 'message': + switch (ev.message.type) { + // テキスト + case 'text': + const res = await this.q(ev.message.text); + if (res == null) return; + // 返信 + this.reply([{ + type: 'text', + text: res + }]); + break; + + // スタンプ + case 'sticker': + // スタンプで返信 + this.reply([{ + type: 'sticker', + packageId: '4', + stickerId: stickers[Math.floor(Math.random() * stickers.length)] + }]); + break; + } + break; + + // noteback + case 'noteback': + const data = ev.noteback.data; + const cmd = data.split('|')[0]; + const arg = data.split('|')[1]; + switch (cmd) { + case 'showtl': + this.showUserTimelineNoteback(arg); + break; + } + break; + } + } + + public static import(data) { + const bot = new LineBot(); + bot._import(data); + return bot; + } + + public async showUserCommand(q: string) { + const user = await require('../../endpoints/users/show')(parseAcct(q.substr(1)), this.user); + + const acct = getAcct(user); + const actions = []; + + actions.push({ + type: 'noteback', + label: 'タイムラインを見る', + data: `showtl|${user.id}` + }); + + if (user.twitter) { + actions.push({ + type: 'uri', + label: 'Twitterアカウントを見る', + uri: `https://twitter.com/${user.twitter.screenName}` + }); + } + + actions.push({ + type: 'uri', + label: 'Webで見る', + uri: `${config.url}/@${acct}` + }); + + this.reply([{ + type: 'template', + altText: await super.showUserCommand(q), + template: { + type: 'buttons', + thumbnailImageUrl: `${user.avatarUrl}?thumbnail&size=1024`, + title: `${getUserName(user)} (@${acct})`, + text: user.description || '(no description)', + actions: actions + } + }]); + + return null; + } + + public async showUserTimelineNoteback(userId: string) { + const tl = await require('../../endpoints/users/notes')({ + userId: userId, + limit: 5 + }, this.user); + + const text = `${getUserName(tl[0].user)}さんのタイムラインはこちらです:\n\n` + tl + .map(note => getNoteSummary(note)) + .join('\n-----\n'); + + this.reply([{ + type: 'text', + text: text + }]); + } +} + +module.exports = async (app: express.Application) => { + if (config.line_bot == null) return; + + const handler = new EventEmitter(); + + handler.on('event', async (ev) => { + + const sourceId = ev.source.userId; + const sessionId = `line-bot-sessions:${sourceId}`; + + const session = await redis.get(sessionId); + let bot: LineBot; + + if (session == null) { + const user = await User.findOne({ + host: null, + 'line': { + userId: sourceId + } + }); + + bot = new LineBot(user); + + bot.on('signin', user => { + User.update(user._id, { + $set: { + 'line': { + userId: sourceId + } + } + }); + }); + + bot.on('signout', user => { + User.update(user._id, { + $set: { + 'line': { + userId: null + } + } + }); + }); + + redis.set(sessionId, JSON.stringify(bot.export())); + } else { + bot = LineBot.import(JSON.parse(session)); + } + + bot.on('updated', () => { + redis.set(sessionId, JSON.stringify(bot.export())); + }); + + if (session != null) bot.refreshUser(); + + bot.react(ev); + }); + + app.post('/hooks/line', (req, res, next) => { + // req.headers['x-line-signature'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + const sig1 = req.headers['x-line-signature'] as string; + + const hash = crypto.createHmac('SHA256', config.line_bot.channel_secret) + .update((req as any).rawBody); + + const sig2 = hash.digest('base64'); + + // シグネチャ比較 + if (sig1 === sig2) { + req.body.events.forEach(ev => { + handler.emit('event', ev); + }); + + res.sendStatus(200); + } else { + res.sendStatus(400); + } + }); +}; diff --git a/src/server/api/call.ts b/src/server/api/call.ts new file mode 100644 index 0000000000..1bfe94bb74 --- /dev/null +++ b/src/server/api/call.ts @@ -0,0 +1,55 @@ +import * as express from 'express'; + +import endpoints, { Endpoint } from './endpoints'; +import limitter from './limitter'; +import { IUser } from '../../models/user'; +import { IApp } from '../../models/app'; + +export default (endpoint: string | Endpoint, user: IUser, app: IApp, data: any, req?: express.Request) => new Promise(async (ok, rej) => { + const isSecure = user != null && app == null; + + //console.log(endpoint, user, app, data); + + const ep = typeof endpoint == 'string' ? endpoints.find(e => e.name == endpoint) : endpoint; + + if (ep.secure && !isSecure) { + return rej('ACCESS_DENIED'); + } + + if (ep.withCredential && user == null) { + return rej('SIGNIN_REQUIRED'); + } + + if (app && ep.kind) { + if (!app.permission.some(p => p === ep.kind)) { + return rej('PERMISSION_DENIED'); + } + } + + if (ep.withCredential && ep.limit) { + try { + await limitter(ep, user); // Rate limit + } catch (e) { + // drop request if limit exceeded + return rej('RATE_LIMIT_EXCEEDED'); + } + } + + let exec = require(`${__dirname}/endpoints/${ep.name}`); + + if (ep.withFile && req) { + exec = exec.bind(null, req.file); + } + + let res; + + // API invoking + try { + res = await exec(data, user, app); + } catch (e) { + rej(e); + return; + } + + ok(res); +}); diff --git a/src/server/api/common/generate-native-user-token.ts b/src/server/api/common/generate-native-user-token.ts new file mode 100644 index 0000000000..2082b89a5a --- /dev/null +++ b/src/server/api/common/generate-native-user-token.ts @@ -0,0 +1,3 @@ +import rndstr from 'rndstr'; + +export default () => `!${rndstr('a-zA-Z0-9', 32)}`; diff --git a/src/server/api/common/get-friends.ts b/src/server/api/common/get-friends.ts new file mode 100644 index 0000000000..c1cc3957d8 --- /dev/null +++ b/src/server/api/common/get-friends.ts @@ -0,0 +1,24 @@ +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({ + followerId: me + }, { + fields: { + followeeId: true + } + }); + + // ID list of other users who the I follows + const myfollowingIds = myfollowing.map(follow => follow.followeeId); + + if (includeMe) { + myfollowingIds.push(me); + } + + return myfollowingIds; +}; diff --git a/src/server/api/common/get-host-lower.ts b/src/server/api/common/get-host-lower.ts new file mode 100644 index 0000000000..fc4b30439e --- /dev/null +++ b/src/server/api/common/get-host-lower.ts @@ -0,0 +1,5 @@ +import { toUnicode } from 'punycode'; + +export default host => { + return toUnicode(host).replace(/[A-Z]+/, match => match.toLowerCase()); +}; diff --git a/src/server/api/common/is-native-token.ts b/src/server/api/common/is-native-token.ts new file mode 100644 index 0000000000..0769a4812e --- /dev/null +++ b/src/server/api/common/is-native-token.ts @@ -0,0 +1 @@ +export default (token: string) => token[0] == '!'; diff --git a/src/server/api/common/read-messaging-message.ts b/src/server/api/common/read-messaging-message.ts new file mode 100644 index 0000000000..c52f9363b5 --- /dev/null +++ b/src/server/api/common/read-messaging-message.ts @@ -0,0 +1,66 @@ +import * as mongo from 'mongodb'; +import Message from '../../../models/messaging-message'; +import { IMessagingMessage as IMessage } from '../../../models/messaging-message'; +import publishUserStream from '../../../publishers/stream'; +import { publishMessagingStream } from '../../../publishers/stream'; +import { publishMessagingIndexStream } from '../../../publishers/stream'; + +/** + * Mark as read message(s) + */ +export default ( + user: string | mongo.ObjectID, + otherparty: string | mongo.ObjectID, + message: string | string[] | IMessage | IMessage[] | mongo.ObjectID | mongo.ObjectID[] +) => new Promise<any>(async (resolve, reject) => { + + const userId = mongo.ObjectID.prototype.isPrototypeOf(user) + ? user + : new mongo.ObjectID(user); + + const otherpartyId = mongo.ObjectID.prototype.isPrototypeOf(otherparty) + ? otherparty + : new mongo.ObjectID(otherparty); + + const ids: mongo.ObjectID[] = Array.isArray(message) + ? mongo.ObjectID.prototype.isPrototypeOf(message[0]) + ? (message as mongo.ObjectID[]) + : typeof message[0] === 'string' + ? (message as string[]).map(m => new mongo.ObjectID(m)) + : (message as IMessage[]).map(m => m._id) + : mongo.ObjectID.prototype.isPrototypeOf(message) + ? [(message as mongo.ObjectID)] + : typeof message === 'string' + ? [new mongo.ObjectID(message)] + : [(message as IMessage)._id]; + + // Update documents + await Message.update({ + _id: { $in: ids }, + userId: otherpartyId, + recipientId: userId, + isRead: false + }, { + $set: { + isRead: true + } + }, { + multi: true + }); + + // Publish event + publishMessagingStream(otherpartyId, userId, 'read', ids.map(id => id.toString())); + publishMessagingIndexStream(userId, 'read', ids.map(id => id.toString())); + + // Calc count of my unread messages + const count = await Message + .count({ + recipientId: userId, + isRead: false + }); + + if (count == 0) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + publishUserStream(userId, 'read_all_messaging_messages'); + } +}); diff --git a/src/server/api/common/read-notification.ts b/src/server/api/common/read-notification.ts new file mode 100644 index 0000000000..9bd41519fb --- /dev/null +++ b/src/server/api/common/read-notification.ts @@ -0,0 +1,52 @@ +import * as mongo from 'mongodb'; +import { default as Notification, INotification } from '../../../models/notification'; +import publishUserStream from '../../../publishers/stream'; + +/** + * Mark as read notification(s) + */ +export default ( + user: string | mongo.ObjectID, + message: string | string[] | INotification | INotification[] | mongo.ObjectID | mongo.ObjectID[] +) => new Promise<any>(async (resolve, reject) => { + + const userId = mongo.ObjectID.prototype.isPrototypeOf(user) + ? user + : new mongo.ObjectID(user); + + const ids: mongo.ObjectID[] = Array.isArray(message) + ? mongo.ObjectID.prototype.isPrototypeOf(message[0]) + ? (message as mongo.ObjectID[]) + : typeof message[0] === 'string' + ? (message as string[]).map(m => new mongo.ObjectID(m)) + : (message as INotification[]).map(m => m._id) + : mongo.ObjectID.prototype.isPrototypeOf(message) + ? [(message as mongo.ObjectID)] + : typeof message === 'string' + ? [new mongo.ObjectID(message)] + : [(message as INotification)._id]; + + // Update documents + await Notification.update({ + _id: { $in: ids }, + isRead: false + }, { + $set: { + isRead: true + } + }, { + multi: true + }); + + // Calc count of my unread notifications + const count = await Notification + .count({ + notifieeId: userId, + isRead: false + }); + + if (count == 0) { + // 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行 + publishUserStream(userId, 'read_all_notifications'); + } +}); diff --git a/src/server/api/common/signin.ts b/src/server/api/common/signin.ts new file mode 100644 index 0000000000..8bb327694d --- /dev/null +++ b/src/server/api/common/signin.ts @@ -0,0 +1,19 @@ +import config from '../../../config'; + +export default function(res, user, redirect: boolean) { + const expires = 1000 * 60 * 60 * 24 * 365; // One Year + res.cookie('i', user.token, { + path: '/', + domain: `.${config.hostname}`, + secure: config.url.substr(0, 5) === 'https', + httpOnly: false, + expires: new Date(Date.now() + expires), + maxAge: expires + }); + + if (redirect) { + res.redirect(config.url); + } else { + res.sendStatus(204); + } +} diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts new file mode 100644 index 0000000000..67f3217faf --- /dev/null +++ b/src/server/api/endpoints.ts @@ -0,0 +1,584 @@ +const ms = require('ms'); + +/** + * エンドポイントを表します。 + */ +export type Endpoint = { + + /** + * エンドポイント名 + */ + name: string; + + /** + * このエンドポイントにリクエストするのにユーザー情報が必須か否か + * 省略した場合は false として解釈されます。 + */ + withCredential?: boolean; + + /** + * エンドポイントのリミテーションに関するやつ + * 省略した場合はリミテーションは無いものとして解釈されます。 + * また、withCredential が false の場合はリミテーションを行うことはできません。 + */ + limit?: { + + /** + * 複数のエンドポイントでリミットを共有したい場合に指定するキー + */ + key?: string; + + /** + * リミットを適用する期間(ms) + * このプロパティを設定する場合、max プロパティも設定する必要があります。 + */ + duration?: number; + + /** + * durationで指定した期間内にいくつまでリクエストできるのか + * このプロパティを設定する場合、duration プロパティも設定する必要があります。 + */ + max?: number; + + /** + * 最低でもどれくらいの間隔を開けてリクエストしなければならないか(ms) + */ + minInterval?: number; + }; + + /** + * ファイルの添付を必要とするか否か + * 省略した場合は false として解釈されます。 + */ + withFile?: boolean; + + /** + * サードパーティアプリからはリクエストすることができないか否か + * 省略した場合は false として解釈されます。 + */ + secure?: boolean; + + /** + * エンドポイントの種類 + * パーミッションの実現に利用されます。 + */ + kind?: string; +}; + +const endpoints: Endpoint[] = [ + { + name: 'meta' + }, + { + name: 'stats' + }, + { + name: 'username/available' + }, + { + name: 'my/apps', + withCredential: true + }, + { + name: 'app/create', + withCredential: true, + limit: { + duration: ms('1day'), + max: 3 + } + }, + { + name: 'app/show' + }, + { + name: 'app/name_id/available' + }, + { + name: 'auth/session/generate' + }, + { + name: 'auth/session/show' + }, + { + name: 'auth/session/userkey' + }, + { + name: 'auth/accept', + withCredential: true, + secure: true + }, + { + name: 'auth/deny', + withCredential: true, + secure: true + }, + { + name: 'aggregation/notes', + }, + { + name: 'aggregation/users', + }, + { + name: 'aggregation/users/activity', + }, + { + name: 'aggregation/users/note', + }, + { + name: 'aggregation/users/followers' + }, + { + name: 'aggregation/users/following' + }, + { + name: 'aggregation/users/reaction' + }, + { + name: 'aggregation/notes/renote' + }, + { + name: 'aggregation/notes/reply' + }, + { + name: 'aggregation/notes/reaction' + }, + { + name: 'aggregation/notes/reactions' + }, + + { + name: 'sw/register', + withCredential: true + }, + + { + name: 'i', + withCredential: true + }, + { + name: 'i/2fa/register', + withCredential: true, + secure: true + }, + { + name: 'i/2fa/unregister', + withCredential: true, + secure: true + }, + { + name: 'i/2fa/done', + withCredential: true, + secure: true + }, + { + name: 'i/update', + withCredential: true, + limit: { + duration: ms('1day'), + max: 50 + }, + kind: 'account-write' + }, + { + name: 'i/update_home', + withCredential: true, + secure: true + }, + { + name: 'i/update_mobile_home', + withCredential: true, + secure: true + }, + { + name: 'i/change_password', + withCredential: true, + secure: true + }, + { + name: 'i/regenerate_token', + withCredential: true, + secure: true + }, + { + name: 'i/update_client_setting', + withCredential: true, + secure: true + }, + { + name: 'i/pin', + kind: 'account-write' + }, + { + name: 'i/appdata/get', + withCredential: true + }, + { + name: 'i/appdata/set', + withCredential: true + }, + { + name: 'i/signin_history', + withCredential: true, + kind: 'account-read' + }, + { + name: 'i/authorized_apps', + withCredential: true, + secure: true + }, + + { + name: 'i/notifications', + withCredential: true, + kind: 'notification-read' + }, + + { + name: 'othello/match', + withCredential: true + }, + + { + name: 'othello/match/cancel', + withCredential: true + }, + + { + name: 'othello/invitations', + withCredential: true + }, + + { + name: 'othello/games', + withCredential: true + }, + + { + name: 'othello/games/show' + }, + + { + name: 'mute/create', + withCredential: true, + kind: 'account/write' + }, + { + name: 'mute/delete', + withCredential: true, + kind: 'account/write' + }, + { + name: 'mute/list', + withCredential: true, + kind: 'account/read' + }, + + { + name: 'notifications/get_unread_count', + withCredential: true, + kind: 'notification-read' + }, + { + name: 'notifications/delete', + withCredential: true, + kind: 'notification-write' + }, + { + name: 'notifications/delete_all', + withCredential: true, + kind: 'notification-write' + }, + { + name: 'notifications/mark_as_read_all', + withCredential: true, + kind: 'notification-write' + }, + + { + name: 'drive', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/stream', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/files', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/files/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + withFile: true, + kind: 'drive-write' + }, + { + name: 'drive/files/upload_from_url', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 10 + }, + kind: 'drive-write' + }, + { + name: 'drive/files/show', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/files/find', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/files/delete', + withCredential: true, + kind: 'drive-write' + }, + { + name: 'drive/files/update', + withCredential: true, + kind: 'drive-write' + }, + { + name: 'drive/folders', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/folders/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 50 + }, + kind: 'drive-write' + }, + { + name: 'drive/folders/show', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/folders/find', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/folders/update', + withCredential: true, + kind: 'drive-write' + }, + + { + name: 'users' + }, + { + name: 'users/show' + }, + { + name: 'users/search' + }, + { + name: 'users/search_by_username' + }, + { + name: 'users/notes' + }, + { + name: 'users/following' + }, + { + name: 'users/followers' + }, + { + name: 'users/recommendation', + withCredential: true, + kind: 'account-read' + }, + { + name: 'users/get_frequently_replied_users' + }, + + { + name: 'following/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'following-write' + }, + { + name: 'following/delete', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'following-write' + }, + + { + name: 'notes' + }, + { + name: 'notes/show' + }, + { + name: 'notes/replies' + }, + { + name: 'notes/context' + }, + { + name: 'notes/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 120, + minInterval: ms('1second') + }, + kind: 'note-write' + }, + { + name: 'notes/renotes' + }, + { + name: 'notes/search' + }, + { + name: 'notes/timeline', + withCredential: true, + limit: { + duration: ms('10minutes'), + max: 100 + } + }, + { + name: 'notes/mentions', + withCredential: true, + limit: { + duration: ms('10minutes'), + max: 100 + } + }, + { + name: 'notes/trend', + withCredential: true + }, + { + name: 'notes/categorize', + withCredential: true + }, + { + name: 'notes/reactions', + withCredential: true + }, + { + name: 'notes/reactions/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'reaction-write' + }, + { + name: 'notes/reactions/delete', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'reaction-write' + }, + { + name: 'notes/favorites/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'favorite-write' + }, + { + name: 'notes/favorites/delete', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'favorite-write' + }, + { + name: 'notes/polls/vote', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'vote-write' + }, + { + name: 'notes/polls/recommendation', + withCredential: true + }, + + { + name: 'messaging/history', + withCredential: true, + kind: 'messaging-read' + }, + { + name: 'messaging/unread', + withCredential: true, + kind: 'messaging-read' + }, + { + name: 'messaging/messages', + withCredential: true, + kind: 'messaging-read' + }, + { + name: 'messaging/messages/create', + withCredential: true, + kind: 'messaging-write' + }, + { + name: 'channels/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 3, + minInterval: ms('10seconds') + } + }, + { + name: 'channels/show' + }, + { + name: 'channels/notes' + }, + { + name: 'channels/watch', + withCredential: true + }, + { + name: 'channels/unwatch', + withCredential: true + }, + { + name: 'channels' + }, +]; + +export default endpoints; diff --git a/src/server/api/endpoints/aggregation/posts.ts b/src/server/api/endpoints/aggregation/posts.ts new file mode 100644 index 0000000000..cc2a48b53d --- /dev/null +++ b/src/server/api/endpoints/aggregation/posts.ts @@ -0,0 +1,90 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Note from '../../../../models/note'; + +/** + * Aggregate notes + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = params => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 365, limitErr] = $(params.limit).optional.number().range(1, 365).$; + if (limitErr) return rej('invalid limit param'); + + const datas = await Note + .aggregate([ + { $project: { + renoteId: '$renoteId', + replyId: '$replyId', + createdAt: { $add: ['$createdAt', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$createdAt' }, + month: { $month: '$createdAt' }, + day: { $dayOfMonth: '$createdAt' } + }, + type: { + $cond: { + if: { $ne: ['$renoteId', null] }, + then: 'renote', + else: { + $cond: { + if: { $ne: ['$replyId', null] }, + then: 'reply', + else: 'note' + } + } + } + }} + }, + { $group: { _id: { + date: '$date', + type: '$type' + }, count: { $sum: 1 } } }, + { $group: { + _id: '$_id.date', + data: { $addToSet: { + type: '$_id.type', + count: '$count' + }} + } } + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + + data.notes = (data.data.filter(x => x.type == 'note')[0] || { count: 0 }).count; + data.renotes = (data.data.filter(x => x.type == 'renote')[0] || { count: 0 }).count; + data.replies = (data.data.filter(x => x.type == 'reply')[0] || { count: 0 }).count; + + delete data.data; + }); + + const graph = []; + + for (let i = 0; i < limit; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + notes: 0, + renotes: 0, + replies: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users.ts b/src/server/api/endpoints/aggregation/users.ts new file mode 100644 index 0000000000..19776ed297 --- /dev/null +++ b/src/server/api/endpoints/aggregation/users.ts @@ -0,0 +1,61 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; + +/** + * Aggregate users + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = params => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 365, limitErr] = $(params.limit).optional.number().range(1, 365).$; + if (limitErr) return rej('invalid limit param'); + + const users = await User + .find({}, { + sort: { + _id: -1 + }, + fields: { + _id: false, + createdAt: true, + deletedAt: true + } + }); + + const graph = []; + + for (let i = 0; i < limit; i++) { + let dayStart = new Date(new Date().setDate(new Date().getDate() - i)); + dayStart = new Date(dayStart.setMilliseconds(0)); + dayStart = new Date(dayStart.setSeconds(0)); + dayStart = new Date(dayStart.setMinutes(0)); + dayStart = new Date(dayStart.setHours(0)); + + let dayEnd = new Date(new Date().setDate(new Date().getDate() - i)); + dayEnd = new Date(dayEnd.setMilliseconds(999)); + dayEnd = new Date(dayEnd.setSeconds(59)); + dayEnd = new Date(dayEnd.setMinutes(59)); + dayEnd = new Date(dayEnd.setHours(23)); + // day = day.getTime(); + + const total = users.filter(u => + u.createdAt < dayEnd && (u.deletedAt == null || u.deletedAt > dayEnd) + ).length; + + const created = users.filter(u => + u.createdAt < dayEnd && u.createdAt > dayStart + ).length; + + graph.push({ + total: total, + created: created + }); + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users/activity.ts b/src/server/api/endpoints/aggregation/users/activity.ts new file mode 100644 index 0000000000..318cce77a5 --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/activity.ts @@ -0,0 +1,116 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../../models/user'; +import Note from '../../../../../models/note'; + +// TODO: likeやfollowも集計 + +/** + * Aggregate activity of a user + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 365, limitErr] = $(params.limit).optional.number().range(1, 365).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).id().$; + if (userIdErr) return rej('invalid userId param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Note + .aggregate([ + { $match: { userId: user._id } }, + { $project: { + renoteId: '$renoteId', + replyId: '$replyId', + createdAt: { $add: ['$createdAt', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$createdAt' }, + month: { $month: '$createdAt' }, + day: { $dayOfMonth: '$createdAt' } + }, + type: { + $cond: { + if: { $ne: ['$renoteId', null] }, + then: 'renote', + else: { + $cond: { + if: { $ne: ['$replyId', null] }, + then: 'reply', + else: 'note' + } + } + } + }} + }, + { $group: { _id: { + date: '$date', + type: '$type' + }, count: { $sum: 1 } } }, + { $group: { + _id: '$_id.date', + data: { $addToSet: { + type: '$_id.type', + count: '$count' + }} + } } + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + + data.notes = (data.data.filter(x => x.type == 'note')[0] || { count: 0 }).count; + data.renotes = (data.data.filter(x => x.type == 'renote')[0] || { count: 0 }).count; + data.replies = (data.data.filter(x => x.type == 'reply')[0] || { count: 0 }).count; + + delete data.data; + }); + + const graph = []; + + for (let i = 0; i < limit; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + notes: 0, + renotes: 0, + replies: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users/followers.ts b/src/server/api/endpoints/aggregation/users/followers.ts new file mode 100644 index 0000000000..7ccb2a3066 --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/followers.ts @@ -0,0 +1,64 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../../models/user'; +import FollowedLog from '../../../../../models/followed-log'; + +/** + * Aggregate followers of a user + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).id().$; + if (userIdErr) return rej('invalid userId param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const today = new Date(); + const graph = []; + + today.setMinutes(0); + today.setSeconds(0); + today.setMilliseconds(0); + + let cursorDate = new Date(today.getTime()); + let cursorTime = cursorDate.setDate(new Date(today.getTime()).getDate() + 1); + + for (let i = 0; i < 30; i++) { + graph.push(FollowedLog.findOne({ + createdAt: { $lt: new Date(cursorTime / 1000) }, + userId: user._id + }, { + sort: { createdAt: -1 }, + }).then(log => { + cursorDate = new Date(today.getTime()); + cursorTime = cursorDate.setDate(today.getDate() - i); + + return { + date: { + year: cursorDate.getFullYear(), + month: cursorDate.getMonth() + 1, // In JavaScript, month is zero-based. + day: cursorDate.getDate() + }, + count: log ? log.count : 0 + }; + })); + } + + res(await Promise.all(graph)); +}); diff --git a/src/server/api/endpoints/aggregation/users/following.ts b/src/server/api/endpoints/aggregation/users/following.ts new file mode 100644 index 0000000000..45e246495b --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/following.ts @@ -0,0 +1,64 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../../models/user'; +import FollowingLog from '../../../../../models/following-log'; + +/** + * Aggregate following of a user + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).id().$; + if (userIdErr) return rej('invalid userId param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const today = new Date(); + const graph = []; + + today.setMinutes(0); + today.setSeconds(0); + today.setMilliseconds(0); + + let cursorDate = new Date(today.getTime()); + let cursorTime = cursorDate.setDate(new Date(today.getTime()).getDate() + 1); + + for (let i = 0; i < 30; i++) { + graph.push(FollowingLog.findOne({ + createdAt: { $lt: new Date(cursorTime / 1000) }, + userId: user._id + }, { + sort: { createdAt: -1 }, + }).then(log => { + cursorDate = new Date(today.getTime()); + cursorTime = cursorDate.setDate(today.getDate() - i); + + return { + date: { + year: cursorDate.getFullYear(), + month: cursorDate.getMonth() + 1, // In JavaScript, month is zero-based. + day: cursorDate.getDate() + }, + count: log ? log.count : 0 + }; + })); + } + + res(await Promise.all(graph)); +}); diff --git a/src/server/api/endpoints/aggregation/users/post.ts b/src/server/api/endpoints/aggregation/users/post.ts new file mode 100644 index 0000000000..e6170d83e2 --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/post.ts @@ -0,0 +1,110 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../../models/user'; +import Note from '../../../../../models/note'; + +/** + * Aggregate note of a user + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).id().$; + if (userIdErr) return rej('invalid userId param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Note + .aggregate([ + { $match: { userId: user._id } }, + { $project: { + renoteId: '$renoteId', + replyId: '$replyId', + createdAt: { $add: ['$createdAt', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$createdAt' }, + month: { $month: '$createdAt' }, + day: { $dayOfMonth: '$createdAt' } + }, + type: { + $cond: { + if: { $ne: ['$renoteId', null] }, + then: 'renote', + else: { + $cond: { + if: { $ne: ['$replyId', null] }, + then: 'reply', + else: 'note' + } + } + } + }} + }, + { $group: { _id: { + date: '$date', + type: '$type' + }, count: { $sum: 1 } } }, + { $group: { + _id: '$_id.date', + data: { $addToSet: { + type: '$_id.type', + count: '$count' + }} + } } + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + + data.notes = (data.data.filter(x => x.type == 'note')[0] || { count: 0 }).count; + data.renotes = (data.data.filter(x => x.type == 'renote')[0] || { count: 0 }).count; + data.replies = (data.data.filter(x => x.type == 'reply')[0] || { count: 0 }).count; + + delete data.data; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + notes: 0, + renotes: 0, + replies: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users/reaction.ts b/src/server/api/endpoints/aggregation/users/reaction.ts new file mode 100644 index 0000000000..881c7ea693 --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/reaction.ts @@ -0,0 +1,80 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../../models/user'; +import Reaction from '../../../../../models/note-reaction'; + +/** + * Aggregate reaction of a user + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).id().$; + if (userIdErr) return rej('invalid userId param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Reaction + .aggregate([ + { $match: { userId: user._id } }, + { $project: { + createdAt: { $add: ['$createdAt', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$createdAt' }, + month: { $month: '$createdAt' }, + day: { $dayOfMonth: '$createdAt' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/app/create.ts b/src/server/api/endpoints/app/create.ts new file mode 100644 index 0000000000..4a55d33f2d --- /dev/null +++ b/src/server/api/endpoints/app/create.ts @@ -0,0 +1,108 @@ +/** + * Module dependencies + */ +import rndstr from 'rndstr'; +import $ from 'cafy'; +import App, { isValidNameId, pack } from '../../../../models/app'; + +/** + * @swagger + * /app/create: + * note: + * summary: Create an application + * parameters: + * - $ref: "#/parameters/AccessToken" + * - + * name: nameId + * description: Application unique name + * in: formData + * required: true + * type: string + * - + * name: name + * description: Application name + * in: formData + * required: true + * type: string + * - + * name: description + * description: Application description + * in: formData + * required: true + * type: string + * - + * name: permission + * description: Permissions that application has + * in: formData + * required: true + * type: array + * items: + * type: string + * collectionFormat: csv + * - + * name: callbackUrl + * description: URL called back after authentication + * in: formData + * required: false + * type: string + * + * responses: + * 200: + * description: Created application's information + * schema: + * $ref: "#/definitions/Application" + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Create an app + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'nameId' parameter + const [nameId, nameIdErr] = $(params.nameId).string().pipe(isValidNameId).$; + if (nameIdErr) return rej('invalid nameId param'); + + // Get 'name' parameter + const [name, nameErr] = $(params.name).string().$; + if (nameErr) return rej('invalid name param'); + + // Get 'description' parameter + const [description, descriptionErr] = $(params.description).string().$; + if (descriptionErr) return rej('invalid description param'); + + // Get 'permission' parameter + const [permission, permissionErr] = $(params.permission).array('string').unique().$; + if (permissionErr) return rej('invalid permission param'); + + // Get 'callbackUrl' parameter + // TODO: Check it is valid url + const [callbackUrl = null, callbackUrlErr] = $(params.callbackUrl).optional.nullable.string().$; + if (callbackUrlErr) return rej('invalid callbackUrl param'); + + // Generate secret + const secret = rndstr('a-zA-Z0-9', 32); + + // Create account + const app = await App.insert({ + createdAt: new Date(), + userId: user._id, + name: name, + nameId: nameId, + nameIdLower: nameId.toLowerCase(), + description: description, + permission: permission, + callbackUrl: callbackUrl, + secret: secret + }); + + // Response + res(await pack(app)); +}); diff --git a/src/server/api/endpoints/app/name_id/available.ts b/src/server/api/endpoints/app/name_id/available.ts new file mode 100644 index 0000000000..ec2d692412 --- /dev/null +++ b/src/server/api/endpoints/app/name_id/available.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import App from '../../../../../models/app'; +import { isValidNameId } from '../../../../../models/app'; + +/** + * @swagger + * /app/nameId/available: + * note: + * summary: Check available nameId on creation an application + * parameters: + * - + * name: nameId + * description: Application unique name + * in: formData + * required: true + * type: string + * + * responses: + * 200: + * description: Success + * schema: + * type: object + * properties: + * available: + * description: Whether nameId is available + * type: boolean + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Check available nameId of app + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = async (params) => new Promise(async (res, rej) => { + // Get 'nameId' parameter + const [nameId, nameIdErr] = $(params.nameId).string().pipe(isValidNameId).$; + if (nameIdErr) return rej('invalid nameId param'); + + // Get exist + const exist = await App + .count({ + nameIdLower: nameId.toLowerCase() + }, { + limit: 1 + }); + + // Reply + res({ + available: exist === 0 + }); +}); diff --git a/src/server/api/endpoints/app/show.ts b/src/server/api/endpoints/app/show.ts new file mode 100644 index 0000000000..99a2093b68 --- /dev/null +++ b/src/server/api/endpoints/app/show.ts @@ -0,0 +1,68 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import App, { pack } from '../../../../models/app'; + +/** + * @swagger + * /app/show: + * note: + * summary: Show an application's information + * description: Require appId or nameId + * parameters: + * - + * name: appId + * description: Application ID + * in: formData + * type: string + * - + * name: nameId + * description: Application unique name + * in: formData + * type: string + * + * responses: + * 200: + * description: Success + * schema: + * $ref: "#/definitions/Application" + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Show an app + */ +module.exports = (params, user, app) => new Promise(async (res, rej) => { + const isSecure = user != null && app == null; + + // Get 'appId' parameter + const [appId, appIdErr] = $(params.appId).optional.id().$; + if (appIdErr) return rej('invalid appId param'); + + // Get 'nameId' parameter + const [nameId, nameIdErr] = $(params.nameId).optional.string().$; + if (nameIdErr) return rej('invalid nameId param'); + + if (appId === undefined && nameId === undefined) { + return rej('appId or nameId is required'); + } + + // Lookup app + const ap = appId !== undefined + ? await App.findOne({ _id: appId }) + : await App.findOne({ nameIdLower: nameId.toLowerCase() }); + + if (ap === null) { + return rej('app not found'); + } + + // Send response + res(await pack(ap, user, { + includeSecret: isSecure && ap.userId.equals(user._id) + })); +}); diff --git a/src/server/api/endpoints/auth/accept.ts b/src/server/api/endpoints/auth/accept.ts new file mode 100644 index 0000000000..b6297d663d --- /dev/null +++ b/src/server/api/endpoints/auth/accept.ts @@ -0,0 +1,93 @@ +/** + * Module dependencies + */ +import rndstr from 'rndstr'; +const crypto = require('crypto'); +import $ from 'cafy'; +import App from '../../../../models/app'; +import AuthSess from '../../../../models/auth-session'; +import AccessToken from '../../../../models/access-token'; + +/** + * @swagger + * /auth/accept: + * note: + * summary: Accept a session + * parameters: + * - $ref: "#/parameters/NativeToken" + * - + * name: token + * description: Session Token + * in: formData + * required: true + * type: string + * responses: + * 204: + * description: OK + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Accept + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'token' parameter + const [token, tokenErr] = $(params.token).string().$; + if (tokenErr) return rej('invalid token param'); + + // Fetch token + const session = await AuthSess + .findOne({ token: token }); + + if (session === null) { + return rej('session not found'); + } + + // Generate access token + const accessToken = rndstr('a-zA-Z0-9', 32); + + // Fetch exist access token + const exist = await AccessToken.findOne({ + appId: session.appId, + userId: user._id, + }); + + if (exist === null) { + // Lookup app + const app = await App.findOne({ + _id: session.appId + }); + + // Generate Hash + const sha256 = crypto.createHash('sha256'); + sha256.update(accessToken + app.secret); + const hash = sha256.digest('hex'); + + // Insert access token doc + await AccessToken.insert({ + createdAt: new Date(), + appId: session.appId, + userId: user._id, + token: accessToken, + hash: hash + }); + } + + // Update session + await AuthSess.update(session._id, { + $set: { + userId: user._id + } + }); + + // Response + res(); +}); diff --git a/src/server/api/endpoints/auth/session/generate.ts b/src/server/api/endpoints/auth/session/generate.ts new file mode 100644 index 0000000000..7c475dbe26 --- /dev/null +++ b/src/server/api/endpoints/auth/session/generate.ts @@ -0,0 +1,76 @@ +/** + * Module dependencies + */ +import * as uuid from 'uuid'; +import $ from 'cafy'; +import App from '../../../../../models/app'; +import AuthSess from '../../../../../models/auth-session'; +import config from '../../../../../config'; + +/** + * @swagger + * /auth/session/generate: + * note: + * summary: Generate a session + * parameters: + * - + * name: appSecret + * description: App Secret + * in: formData + * required: true + * type: string + * + * responses: + * 200: + * description: OK + * schema: + * type: object + * properties: + * token: + * type: string + * description: Session Token + * url: + * type: string + * description: Authentication form's URL + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Generate a session + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'appSecret' parameter + const [appSecret, appSecretErr] = $(params.appSecret).string().$; + if (appSecretErr) return rej('invalid appSecret param'); + + // Lookup app + const app = await App.findOne({ + secret: appSecret + }); + + if (app == null) { + return rej('app not found'); + } + + // Generate token + const token = uuid.v4(); + + // Create session token document + const doc = await AuthSess.insert({ + createdAt: new Date(), + appId: app._id, + token: token + }); + + // Response + res({ + token: doc.token, + url: `${config.auth_url}/${doc.token}` + }); +}); diff --git a/src/server/api/endpoints/auth/session/show.ts b/src/server/api/endpoints/auth/session/show.ts new file mode 100644 index 0000000000..f7f0b087b7 --- /dev/null +++ b/src/server/api/endpoints/auth/session/show.ts @@ -0,0 +1,70 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import AuthSess, { pack } from '../../../../../models/auth-session'; + +/** + * @swagger + * /auth/session/show: + * note: + * summary: Show a session information + * parameters: + * - + * name: token + * description: Session Token + * in: formData + * required: true + * type: string + * + * responses: + * 200: + * description: OK + * schema: + * type: object + * properties: + * createdAt: + * type: string + * format: date-time + * description: Date and time of the session creation + * appId: + * type: string + * description: Application ID + * token: + * type: string + * description: Session Token + * userId: + * type: string + * description: ID of user who create the session + * app: + * $ref: "#/definitions/Application" + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Show a session + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'token' parameter + const [token, tokenErr] = $(params.token).string().$; + if (tokenErr) return rej('invalid token param'); + + // Lookup session + const session = await AuthSess.findOne({ + token: token + }); + + if (session == null) { + return rej('session not found'); + } + + // Response + res(await pack(session, user)); +}); diff --git a/src/server/api/endpoints/auth/session/userkey.ts b/src/server/api/endpoints/auth/session/userkey.ts new file mode 100644 index 0000000000..ddb67cb451 --- /dev/null +++ b/src/server/api/endpoints/auth/session/userkey.ts @@ -0,0 +1,109 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import App from '../../../../../models/app'; +import AuthSess from '../../../../../models/auth-session'; +import AccessToken from '../../../../../models/access-token'; +import { pack } from '../../../../../models/user'; + +/** + * @swagger + * /auth/session/userkey: + * note: + * summary: Get an access token(userkey) + * parameters: + * - + * name: appSecret + * description: App Secret + * in: formData + * required: true + * type: string + * - + * name: token + * description: Session Token + * in: formData + * required: true + * type: string + * + * responses: + * 200: + * description: OK + * schema: + * type: object + * properties: + * userkey: + * type: string + * description: Access Token + * user: + * $ref: "#/definitions/User" + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Generate a session + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'appSecret' parameter + const [appSecret, appSecretErr] = $(params.appSecret).string().$; + if (appSecretErr) return rej('invalid appSecret param'); + + // Lookup app + const app = await App.findOne({ + secret: appSecret + }); + + if (app == null) { + return rej('app not found'); + } + + // Get 'token' parameter + const [token, tokenErr] = $(params.token).string().$; + if (tokenErr) return rej('invalid token param'); + + // Fetch token + const session = await AuthSess + .findOne({ + token: token, + appId: app._id + }); + + if (session === null) { + return rej('session not found'); + } + + if (session.userId == null) { + return rej('this session is not allowed yet'); + } + + // Lookup access token + const accessToken = await AccessToken.findOne({ + appId: app._id, + userId: session.userId + }); + + // Delete session + + /* https://github.com/Automattic/monk/issues/178 + AuthSess.deleteOne({ + _id: session._id + }); + */ + AuthSess.remove({ + _id: session._id + }); + + // Response + res({ + accessToken: accessToken.token, + user: await pack(session.userId, null, { + detail: true + }) + }); +}); diff --git a/src/server/api/endpoints/channels.ts b/src/server/api/endpoints/channels.ts new file mode 100644 index 0000000000..582e6ba43b --- /dev/null +++ b/src/server/api/endpoints/channels.ts @@ -0,0 +1,58 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel, { pack } from '../../../models/channel'; + +/** + * Get all channels + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = {} as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const channels = await Channel + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(channels.map(async channel => + await pack(channel, me)))); +}); diff --git a/src/server/api/endpoints/channels/create.ts b/src/server/api/endpoints/channels/create.ts new file mode 100644 index 0000000000..0f0f558c8a --- /dev/null +++ b/src/server/api/endpoints/channels/create.ts @@ -0,0 +1,39 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../../../models/channel'; +import Watching from '../../../../models/channel-watching'; +import { pack } from '../../../../models/channel'; + +/** + * Create a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'title' parameter + const [title, titleErr] = $(params.title).string().range(1, 100).$; + if (titleErr) return rej('invalid title param'); + + // Create a channel + const channel = await Channel.insert({ + createdAt: new Date(), + userId: user._id, + title: title, + index: 0, + watchingCount: 1 + }); + + // Response + res(await pack(channel)); + + // Create Watching + await Watching.insert({ + createdAt: new Date(), + userId: user._id, + channelId: channel._id + }); +}); diff --git a/src/server/api/endpoints/channels/notes.ts b/src/server/api/endpoints/channels/notes.ts new file mode 100644 index 0000000000..d636aa0d10 --- /dev/null +++ b/src/server/api/endpoints/channels/notes.ts @@ -0,0 +1,78 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { default as Channel, IChannel } from '../../../../models/channel'; +import Note, { pack } from '../../../../models/note'; + +/** + * Show a notes of a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 1000, limitErr] = $(params.limit).optional.number().range(1, 1000).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Get 'channelId' parameter + const [channelId, channelIdErr] = $(params.channelId).id().$; + if (channelIdErr) return rej('invalid channelId param'); + + // Fetch channel + const channel: IChannel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + channelId: channel._id + } as any; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + //#endregion Construct query + + // Issue query + const notes = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(notes.map(async (note) => + await pack(note, user) + ))); +}); diff --git a/src/server/api/endpoints/channels/show.ts b/src/server/api/endpoints/channels/show.ts new file mode 100644 index 0000000000..3ce9ce4745 --- /dev/null +++ b/src/server/api/endpoints/channels/show.ts @@ -0,0 +1,30 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel, { IChannel, pack } from '../../../../models/channel'; + +/** + * Show a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channelId' parameter + const [channelId, channelIdErr] = $(params.channelId).id().$; + if (channelIdErr) return rej('invalid channelId param'); + + // Fetch channel + const channel: IChannel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + // Serialize + res(await pack(channel, user)); +}); diff --git a/src/server/api/endpoints/channels/unwatch.ts b/src/server/api/endpoints/channels/unwatch.ts new file mode 100644 index 0000000000..8220b90b68 --- /dev/null +++ b/src/server/api/endpoints/channels/unwatch.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../../../models/channel'; +import Watching from '../../../../models/channel-watching'; + +/** + * Unwatch a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channelId' parameter + const [channelId, channelIdErr] = $(params.channelId).id().$; + if (channelIdErr) return rej('invalid channelId param'); + + //#region Fetch channel + const channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + //#endregion + + //#region Check whether not watching + const exist = await Watching.findOne({ + userId: user._id, + channelId: channel._id, + deletedAt: { $exists: false } + }); + + if (exist === null) { + return rej('already not watching'); + } + //#endregion + + // Delete watching + await Watching.update({ + _id: exist._id + }, { + $set: { + deletedAt: new Date() + } + }); + + // Send response + res(); + + // Decrement watching count + Channel.update(channel._id, { + $inc: { + watchingCount: -1 + } + }); +}); diff --git a/src/server/api/endpoints/channels/watch.ts b/src/server/api/endpoints/channels/watch.ts new file mode 100644 index 0000000000..6906282a54 --- /dev/null +++ b/src/server/api/endpoints/channels/watch.ts @@ -0,0 +1,58 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../../../models/channel'; +import Watching from '../../../../models/channel-watching'; + +/** + * Watch a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channelId' parameter + const [channelId, channelIdErr] = $(params.channelId).id().$; + if (channelIdErr) return rej('invalid channelId param'); + + //#region Fetch channel + const channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + //#endregion + + //#region Check whether already watching + const exist = await Watching.findOne({ + userId: user._id, + channelId: channel._id, + deletedAt: { $exists: false } + }); + + if (exist !== null) { + return rej('already watching'); + } + //#endregion + + // Create Watching + await Watching.insert({ + createdAt: new Date(), + userId: user._id, + channelId: channel._id + }); + + // Send response + res(); + + // Increment watching count + Channel.update(channel._id, { + $inc: { + watchingCount: 1 + } + }); +}); diff --git a/src/server/api/endpoints/drive.ts b/src/server/api/endpoints/drive.ts new file mode 100644 index 0000000000..d77ab2bbb0 --- /dev/null +++ b/src/server/api/endpoints/drive.ts @@ -0,0 +1,37 @@ +/** + * Module dependencies + */ +import DriveFile from '../../../models/drive-file'; + +/** + * Get drive information + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Calculate drive usage + const usage = ((await DriveFile + .aggregate([ + { $match: { 'metadata.userId': user._id } }, + { + $project: { + length: true + } + }, + { + $group: { + _id: null, + usage: { $sum: '$length' } + } + } + ]))[0] || { + usage: 0 + }).usage; + + res({ + capacity: user.driveCapacity, + usage: usage + }); +}); diff --git a/src/server/api/endpoints/drive/files.ts b/src/server/api/endpoints/drive/files.ts new file mode 100644 index 0000000000..63d69d145a --- /dev/null +++ b/src/server/api/endpoints/drive/files.ts @@ -0,0 +1,73 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFile, { pack } from '../../../../models/drive-file'; + +/** + * Get drive files + * + * @param {any} params + * @param {any} user + * @param {any} app + * @return {Promise<any>} + */ +module.exports = async (params, user, app) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) throw 'invalid limit param'; + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) throw 'invalid sinceId param'; + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) throw 'invalid untilId param'; + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + throw 'cannot set sinceId and untilId'; + } + + // Get 'folderId' parameter + const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + if (folderIdErr) throw 'invalid folderId param'; + + // Get 'type' parameter + const [type, typeErr] = $(params.type).optional.string().match(/^[a-zA-Z\/\-\*]+$/).$; + if (typeErr) throw 'invalid type param'; + + // Construct query + const sort = { + _id: -1 + }; + const query = { + 'metadata.userId': user._id, + 'metadata.folderId': folderId + } as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + if (type) { + query.contentType = new RegExp(`^${type.replace(/\*/g, '.+?')}$`); + } + + // Issue query + const files = await DriveFile + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + const _files = await Promise.all(files.map(file => pack(file))); + return _files; +}; diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts new file mode 100644 index 0000000000..df0bd0a0d3 --- /dev/null +++ b/src/server/api/endpoints/drive/files/create.ts @@ -0,0 +1,51 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { validateFileName, pack } from '../../../../../models/drive-file'; +import create from '../../../../../services/drive/add-file'; + +/** + * Create a file + * + * @param {any} file + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (file, params, user): Promise<any> => { + if (file == null) { + throw 'file is required'; + } + + // Get 'name' parameter + let name = file.originalname; + if (name !== undefined && name !== null) { + name = name.trim(); + if (name.length === 0) { + name = null; + } else if (name === 'blob') { + name = null; + } else if (!validateFileName(name)) { + throw 'invalid name'; + } + } else { + name = null; + } + + // Get 'folderId' parameter + const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + if (folderIdErr) throw 'invalid folderId param'; + + try { + // Create file + const driveFile = await create(user, file.path, name, null, folderId); + + // Serialize + return pack(driveFile); + } catch (e) { + console.error(e); + + throw e; + } +}; diff --git a/src/server/api/endpoints/drive/files/find.ts b/src/server/api/endpoints/drive/files/find.ts new file mode 100644 index 0000000000..0ab6e5d3e3 --- /dev/null +++ b/src/server/api/endpoints/drive/files/find.ts @@ -0,0 +1,34 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFile, { pack } from '../../../../../models/drive-file'; + +/** + * Find a file(s) + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name, nameErr] = $(params.name).string().$; + if (nameErr) return rej('invalid name param'); + + // Get 'folderId' parameter + const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + if (folderIdErr) return rej('invalid folderId param'); + + // Issue query + const files = await DriveFile + .find({ + filename: name, + 'metadata.userId': user._id, + 'metadata.folderId': folderId + }); + + // Serialize + res(await Promise.all(files.map(async file => + await pack(file)))); +}); diff --git a/src/server/api/endpoints/drive/files/show.ts b/src/server/api/endpoints/drive/files/show.ts new file mode 100644 index 0000000000..3398f24541 --- /dev/null +++ b/src/server/api/endpoints/drive/files/show.ts @@ -0,0 +1,36 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFile, { pack } from '../../../../../models/drive-file'; + +/** + * Show a file + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => { + // Get 'fileId' parameter + const [fileId, fileIdErr] = $(params.fileId).id().$; + if (fileIdErr) throw 'invalid fileId param'; + + // Fetch file + const file = await DriveFile + .findOne({ + _id: fileId, + 'metadata.userId': user._id + }); + + if (file === null) { + throw 'file-not-found'; + } + + // Serialize + const _file = await pack(file, { + detail: true + }); + + return _file; +}; diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts new file mode 100644 index 0000000000..c783ad8b3b --- /dev/null +++ b/src/server/api/endpoints/drive/files/update.ts @@ -0,0 +1,75 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFolder from '../../../../../models/drive-folder'; +import DriveFile, { validateFileName, pack } from '../../../../../models/drive-file'; +import { publishDriveStream } from '../../../../../publishers/stream'; + +/** + * Update a file + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'fileId' parameter + const [fileId, fileIdErr] = $(params.fileId).id().$; + if (fileIdErr) return rej('invalid fileId param'); + + // Fetch file + const file = await DriveFile + .findOne({ + _id: fileId, + 'metadata.userId': user._id + }); + + if (file === null) { + return rej('file-not-found'); + } + + // Get 'name' parameter + const [name, nameErr] = $(params.name).optional.string().pipe(validateFileName).$; + if (nameErr) return rej('invalid name param'); + if (name) file.filename = name; + + // Get 'folderId' parameter + const [folderId, folderIdErr] = $(params.folderId).optional.nullable.id().$; + if (folderIdErr) return rej('invalid folderId param'); + + if (folderId !== undefined) { + if (folderId === null) { + file.metadata.folderId = null; + } else { + // Fetch folder + const folder = await DriveFolder + .findOne({ + _id: folderId, + userId: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + file.metadata.folderId = folder._id; + } + } + + await DriveFile.update(file._id, { + $set: { + filename: file.filename, + 'metadata.folderId': file.metadata.folderId + } + }); + + // Serialize + const fileObj = await pack(file); + + // Response + res(fileObj); + + // Publish file_updated event + publishDriveStream(user._id, 'file_updated', fileObj); +}); diff --git a/src/server/api/endpoints/drive/files/upload_from_url.ts b/src/server/api/endpoints/drive/files/upload_from_url.ts new file mode 100644 index 0000000000..8a426c0efc --- /dev/null +++ b/src/server/api/endpoints/drive/files/upload_from_url.ts @@ -0,0 +1,22 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { pack } from '../../../../../models/drive-file'; +import uploadFromUrl from '../../../../../services/drive/upload-from-url'; + +/** + * Create a file from a URL + */ +module.exports = async (params, user): Promise<any> => { + // Get 'url' parameter + // TODO: Validate this url + const [url, urlErr] = $(params.url).string().$; + if (urlErr) throw 'invalid url param'; + + // Get 'folderId' parameter + const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + if (folderIdErr) throw 'invalid folderId param'; + + return pack(await uploadFromUrl(url, user, folderId)); +}; diff --git a/src/server/api/endpoints/drive/folders.ts b/src/server/api/endpoints/drive/folders.ts new file mode 100644 index 0000000000..489e47912e --- /dev/null +++ b/src/server/api/endpoints/drive/folders.ts @@ -0,0 +1,66 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFolder, { pack } from '../../../../models/drive-folder'; + +/** + * Get drive folders + * + * @param {any} params + * @param {any} user + * @param {any} app + * @return {Promise<any>} + */ +module.exports = (params, user, app) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Get 'folderId' parameter + const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + if (folderIdErr) return rej('invalid folderId param'); + + // Construct query + const sort = { + _id: -1 + }; + const query = { + userId: user._id, + parentId: folderId + } as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const folders = await DriveFolder + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(folders.map(async folder => + await pack(folder)))); +}); diff --git a/src/server/api/endpoints/drive/folders/create.ts b/src/server/api/endpoints/drive/folders/create.ts new file mode 100644 index 0000000000..f34d0019d7 --- /dev/null +++ b/src/server/api/endpoints/drive/folders/create.ts @@ -0,0 +1,55 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder'; +import { publishDriveStream } from '../../../../../publishers/stream'; + +/** + * Create drive folder + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name = '無題のフォルダー', nameErr] = $(params.name).optional.string().pipe(isValidFolderName).$; + if (nameErr) return rej('invalid name param'); + + // Get 'parentId' parameter + const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.id().$; + if (parentIdErr) return rej('invalid parentId param'); + + // If the parent folder is specified + let parent = null; + if (parentId) { + // Fetch parent folder + parent = await DriveFolder + .findOne({ + _id: parentId, + userId: user._id + }); + + if (parent === null) { + return rej('parent-not-found'); + } + } + + // Create folder + const folder = await DriveFolder.insert({ + createdAt: new Date(), + name: name, + parentId: parent !== null ? parent._id : null, + userId: user._id + }); + + // Serialize + const folderObj = await pack(folder); + + // Response + res(folderObj); + + // Publish folder_created event + publishDriveStream(user._id, 'folder_created', folderObj); +}); diff --git a/src/server/api/endpoints/drive/folders/find.ts b/src/server/api/endpoints/drive/folders/find.ts new file mode 100644 index 0000000000..04dc38f87f --- /dev/null +++ b/src/server/api/endpoints/drive/folders/find.ts @@ -0,0 +1,33 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFolder, { pack } from '../../../../../models/drive-folder'; + +/** + * Find a folder(s) + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name, nameErr] = $(params.name).string().$; + if (nameErr) return rej('invalid name param'); + + // Get 'parentId' parameter + const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.id().$; + if (parentIdErr) return rej('invalid parentId param'); + + // Issue query + const folders = await DriveFolder + .find({ + name: name, + userId: user._id, + parentId: parentId + }); + + // Serialize + res(await Promise.all(folders.map(folder => pack(folder)))); +}); diff --git a/src/server/api/endpoints/drive/folders/show.ts b/src/server/api/endpoints/drive/folders/show.ts new file mode 100644 index 0000000000..b432f5a50a --- /dev/null +++ b/src/server/api/endpoints/drive/folders/show.ts @@ -0,0 +1,34 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFolder, { pack } from '../../../../../models/drive-folder'; + +/** + * Show a folder + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'folderId' parameter + const [folderId, folderIdErr] = $(params.folderId).id().$; + if (folderIdErr) return rej('invalid folderId param'); + + // Get folder + const folder = await DriveFolder + .findOne({ + _id: folderId, + userId: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + // Serialize + res(await pack(folder, { + detail: true + })); +}); diff --git a/src/server/api/endpoints/drive/folders/update.ts b/src/server/api/endpoints/drive/folders/update.ts new file mode 100644 index 0000000000..dd7e8f5c86 --- /dev/null +++ b/src/server/api/endpoints/drive/folders/update.ts @@ -0,0 +1,99 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder'; +import { publishDriveStream } from '../../../../../publishers/stream'; + +/** + * Update a folder + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'folderId' parameter + const [folderId, folderIdErr] = $(params.folderId).id().$; + if (folderIdErr) return rej('invalid folderId param'); + + // Fetch folder + const folder = await DriveFolder + .findOne({ + _id: folderId, + userId: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + // Get 'name' parameter + const [name, nameErr] = $(params.name).optional.string().pipe(isValidFolderName).$; + if (nameErr) return rej('invalid name param'); + if (name) folder.name = name; + + // Get 'parentId' parameter + const [parentId, parentIdErr] = $(params.parentId).optional.nullable.id().$; + if (parentIdErr) return rej('invalid parentId param'); + if (parentId !== undefined) { + if (parentId === null) { + folder.parentId = null; + } else { + // Get parent folder + const parent = await DriveFolder + .findOne({ + _id: parentId, + userId: user._id + }); + + if (parent === null) { + return rej('parent-folder-not-found'); + } + + // Check if the circular reference will occur + async function checkCircle(folderId) { + // Fetch folder + const folder2 = await DriveFolder.findOne({ + _id: folderId + }, { + _id: true, + parentId: true + }); + + if (folder2._id.equals(folder._id)) { + return true; + } else if (folder2.parentId) { + return await checkCircle(folder2.parentId); + } else { + return false; + } + } + + if (parent.parentId !== null) { + if (await checkCircle(parent.parentId)) { + return rej('detected-circular-definition'); + } + } + + folder.parentId = parent._id; + } + } + + // Update + DriveFolder.update(folder._id, { + $set: { + name: folder.name, + parentId: folder.parentId + } + }); + + // Serialize + const folderObj = await pack(folder); + + // Response + res(folderObj); + + // Publish folder_updated event + publishDriveStream(user._id, 'folder_updated', folderObj); +}); diff --git a/src/server/api/endpoints/drive/stream.ts b/src/server/api/endpoints/drive/stream.ts new file mode 100644 index 0000000000..02313aa37b --- /dev/null +++ b/src/server/api/endpoints/drive/stream.ts @@ -0,0 +1,67 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFile, { pack } from '../../../../models/drive-file'; + +/** + * Get drive stream + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Get 'type' parameter + const [type, typeErr] = $(params.type).optional.string().match(/^[a-zA-Z\/\-\*]+$/).$; + if (typeErr) return rej('invalid type param'); + + // Construct query + const sort = { + _id: -1 + }; + const query = { + 'metadata.userId': user._id + } as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + if (type) { + query.contentType = new RegExp(`^${type.replace(/\*/g, '.+?')}$`); + } + + // Issue query + const files = await DriveFile + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(files.map(async file => + await pack(file)))); +}); diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts new file mode 100644 index 0000000000..27e5eb31db --- /dev/null +++ b/src/server/api/endpoints/following/create.ts @@ -0,0 +1,53 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import Following from '../../../../models/following'; +import create from '../../../../services/following/create'; + +/** + * Follow a user + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const follower = user; + + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).id().$; + if (userIdErr) return rej('invalid userId param'); + + // 自分自身 + if (user._id.equals(userId)) { + return rej('followee is yourself'); + } + + // Get followee + const followee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + profile: false + } + }); + + if (followee === null) { + return rej('user not found'); + } + + // Check if already following + const exist = await Following.findOne({ + followerId: follower._id, + followeeId: followee._id + }); + + if (exist !== null) { + return rej('already following'); + } + + // Create following + create(follower, followee); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/following/delete.ts b/src/server/api/endpoints/following/delete.ts new file mode 100644 index 0000000000..ca0703ca22 --- /dev/null +++ b/src/server/api/endpoints/following/delete.ts @@ -0,0 +1,53 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import Following from '../../../../models/following'; +import deleteFollowing from '../../../../services/following/delete'; + +/** + * Unfollow a user + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const follower = user; + + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).id().$; + if (userIdErr) return rej('invalid userId param'); + + // Check if the followee is yourself + if (user._id.equals(userId)) { + return rej('followee is yourself'); + } + + // Get followee + const followee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + 'profile': false + } + }); + + if (followee === null) { + return rej('user not found'); + } + + // Check not following + const exist = await Following.findOne({ + followerId: follower._id, + followeeId: followee._id + }); + + if (exist === null) { + return rej('already not following'); + } + + // Delete following + deleteFollowing(follower, followee); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts new file mode 100644 index 0000000000..379c3c4d88 --- /dev/null +++ b/src/server/api/endpoints/i.ts @@ -0,0 +1,24 @@ +/** + * Module dependencies + */ +import User, { pack } from '../../../models/user'; + +/** + * Show myself + */ +module.exports = (params, user, app) => new Promise(async (res, rej) => { + const isSecure = user != null && app == null; + + // Serialize + res(await pack(user, user, { + detail: true, + includeSecrets: isSecure + })); + + // Update lastUsedAt + User.update({ _id: user._id }, { + $set: { + lastUsedAt: new Date() + } + }); +}); diff --git a/src/server/api/endpoints/i/2fa/done.ts b/src/server/api/endpoints/i/2fa/done.ts new file mode 100644 index 0000000000..3e824feffd --- /dev/null +++ b/src/server/api/endpoints/i/2fa/done.ts @@ -0,0 +1,37 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as speakeasy from 'speakeasy'; +import User from '../../../../../models/user'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'token' parameter + const [token, tokenErr] = $(params.token).string().$; + if (tokenErr) return rej('invalid token param'); + + const _token = token.replace(/\s/g, ''); + + if (user.twoFactorTempSecret == null) { + return rej('二段階認証の設定が開始されていません'); + } + + const verified = (speakeasy as any).totp.verify({ + secret: user.twoFactorTempSecret, + encoding: 'base32', + token: _token + }); + + if (!verified) { + return rej('not verified'); + } + + await User.update(user._id, { + $set: { + 'twoFactorSecret': user.twoFactorTempSecret, + 'twoFactorEnabled': true + } + }); + + res(); +}); diff --git a/src/server/api/endpoints/i/2fa/register.ts b/src/server/api/endpoints/i/2fa/register.ts new file mode 100644 index 0000000000..bed64a2545 --- /dev/null +++ b/src/server/api/endpoints/i/2fa/register.ts @@ -0,0 +1,48 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import * as QRCode from 'qrcode'; +import User from '../../../../../models/user'; +import config from '../../../../../config'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'password' parameter + const [password, passwordErr] = $(params.password).string().$; + if (passwordErr) return rej('invalid password param'); + + // Compare password + const same = await bcrypt.compare(password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate user's secret key + const secret = speakeasy.generateSecret({ + length: 32 + }); + + await User.update(user._id, { + $set: { + twoFactorTempSecret: secret.base32 + } + }); + + // Get the data URL of the authenticator URL + QRCode.toDataURL(speakeasy.otpauthURL({ + secret: secret.base32, + encoding: 'base32', + label: user.username, + issuer: config.host + }), (err, data_url) => { + res({ + qr: data_url, + secret: secret.base32, + label: user.username, + issuer: config.host + }); + }); +}); diff --git a/src/server/api/endpoints/i/2fa/unregister.ts b/src/server/api/endpoints/i/2fa/unregister.ts new file mode 100644 index 0000000000..f9d7a25f53 --- /dev/null +++ b/src/server/api/endpoints/i/2fa/unregister.ts @@ -0,0 +1,28 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../../../../models/user'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'password' parameter + const [password, passwordErr] = $(params.password).string().$; + if (passwordErr) return rej('invalid password param'); + + // Compare password + const same = await bcrypt.compare(password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + await User.update(user._id, { + $set: { + 'twoFactorSecret': null, + 'twoFactorEnabled': false + } + }); + + res(); +}); diff --git a/src/server/api/endpoints/i/authorized_apps.ts b/src/server/api/endpoints/i/authorized_apps.ts new file mode 100644 index 0000000000..82fd2d2516 --- /dev/null +++ b/src/server/api/endpoints/i/authorized_apps.ts @@ -0,0 +1,43 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import AccessToken from '../../../../models/access-token'; +import { pack } from '../../../../models/app'; + +/** + * Get authorized apps of my account + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$; + if (sortError) return rej('invalid sort param'); + + // Get tokens + const tokens = await AccessToken + .find({ + userId: user._id + }, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }); + + // Serialize + res(await Promise.all(tokens.map(async token => + await pack(token.appId)))); +}); diff --git a/src/server/api/endpoints/i/change_password.ts b/src/server/api/endpoints/i/change_password.ts new file mode 100644 index 0000000000..57415083f1 --- /dev/null +++ b/src/server/api/endpoints/i/change_password.ts @@ -0,0 +1,42 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../../../models/user'; + +/** + * Change password + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'currentPasword' parameter + const [currentPassword, currentPasswordErr] = $(params.currentPasword).string().$; + if (currentPasswordErr) return rej('invalid currentPasword param'); + + // Get 'newPassword' parameter + const [newPassword, newPasswordErr] = $(params.newPassword).string().$; + if (newPasswordErr) return rej('invalid newPassword param'); + + // Compare password + const same = await bcrypt.compare(currentPassword, user.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(newPassword, salt); + + await User.update(user._id, { + $set: { + 'password': hash + } + }); + + res(); +}); diff --git a/src/server/api/endpoints/i/favorites.ts b/src/server/api/endpoints/i/favorites.ts new file mode 100644 index 0000000000..b40f2b3887 --- /dev/null +++ b/src/server/api/endpoints/i/favorites.ts @@ -0,0 +1,44 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Favorite from '../../../../models/favorite'; +import { pack } from '../../../../models/note'; + +/** + * Get followers of a user + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$; + if (sortError) return rej('invalid sort param'); + + // Get favorites + const favorites = await Favorite + .find({ + userId: user._id + }, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }); + + // Serialize + res(await Promise.all(favorites.map(async favorite => + await pack(favorite.noteId) + ))); +}); diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts new file mode 100644 index 0000000000..3b4899682d --- /dev/null +++ b/src/server/api/endpoints/i/notifications.ts @@ -0,0 +1,106 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Notification from '../../../../models/notification'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/notification'; +import getFriends from '../../common/get-friends'; +import read from '../../common/read-notification'; + +/** + * Get notifications + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'following' parameter + const [following = false, followingError] = + $(params.following).optional.boolean().$; + if (followingError) return rej('invalid following param'); + + // Get 'markAsRead' parameter + const [markAsRead = true, markAsReadErr] = $(params.markAsRead).optional.boolean().$; + if (markAsReadErr) return rej('invalid markAsRead param'); + + // Get 'type' parameter + const [type, typeErr] = $(params.type).optional.array('string').unique().$; + if (typeErr) return rej('invalid type param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + const mute = await Mute.find({ + muterId: user._id, + deletedAt: { $exists: false } + }); + + const query = { + notifieeId: user._id, + $and: [{ + notifierId: { + $nin: mute.map(m => m.muteeId) + } + }] + } as any; + + const sort = { + _id: -1 + }; + + if (following) { + // ID list of the user itself and other users who the user follows + const followingIds = await getFriends(user._id); + + query.$and.push({ + notifierId: { + $in: followingIds + } + }); + } + + if (type) { + query.type = { + $in: type + }; + } + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const notifications = await Notification + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(notifications.map(async notification => + await pack(notification)))); + + // Mark as read all + if (notifications.length > 0 && markAsRead) { + read(user._id, notifications); + } +}); diff --git a/src/server/api/endpoints/i/pin.ts b/src/server/api/endpoints/i/pin.ts new file mode 100644 index 0000000000..909a6fdbde --- /dev/null +++ b/src/server/api/endpoints/i/pin.ts @@ -0,0 +1,44 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import Note from '../../../../models/note'; +import { pack } from '../../../../models/user'; + +/** + * Pin note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $(params.noteId).id().$; + if (noteIdErr) return rej('invalid noteId param'); + + // Fetch pinee + const note = await Note.findOne({ + _id: noteId, + userId: user._id + }); + + if (note === null) { + return rej('note not found'); + } + + await User.update(user._id, { + $set: { + pinnedNoteId: note._id + } + }); + + // Serialize + const iObj = await pack(user, user, { + detail: true + }); + + // Send response + res(iObj); +}); diff --git a/src/server/api/endpoints/i/regenerate_token.ts b/src/server/api/endpoints/i/regenerate_token.ts new file mode 100644 index 0000000000..f9e92c1797 --- /dev/null +++ b/src/server/api/endpoints/i/regenerate_token.ts @@ -0,0 +1,42 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../../../models/user'; +import event from '../../../../publishers/stream'; +import generateUserToken from '../../common/generate-native-user-token'; + +/** + * Regenerate native token + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'password' parameter + const [password, passwordErr] = $(params.password).string().$; + if (passwordErr) return rej('invalid password param'); + + // Compare password + const same = await bcrypt.compare(password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate secret + const secret = generateUserToken(); + + await User.update(user._id, { + $set: { + 'token': secret + } + }); + + res(); + + // Publish event + event(user._id, 'my_token_regenerated'); +}); diff --git a/src/server/api/endpoints/i/signin_history.ts b/src/server/api/endpoints/i/signin_history.ts new file mode 100644 index 0000000000..931b9e2252 --- /dev/null +++ b/src/server/api/endpoints/i/signin_history.ts @@ -0,0 +1,61 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Signin, { pack } from '../../../../models/signin'; + +/** + * Get signin history of my account + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + const query = { + userId: user._id + } as any; + + const sort = { + _id: -1 + }; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const history = await Signin + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(history.map(async record => + await pack(record)))); +}); diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts new file mode 100644 index 0000000000..f3c9d777b5 --- /dev/null +++ b/src/server/api/endpoints/i/update.ts @@ -0,0 +1,77 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { isValidName, isValidDescription, isValidLocation, isValidBirthday, pack } from '../../../../models/user'; +import event from '../../../../publishers/stream'; + +/** + * Update myself + */ +module.exports = async (params, user, app) => new Promise(async (res, rej) => { + const isSecure = user != null && app == null; + + // Get 'name' parameter + const [name, nameErr] = $(params.name).optional.nullable.string().pipe(isValidName).$; + if (nameErr) return rej('invalid name param'); + if (name) user.name = name; + + // Get 'description' parameter + const [description, descriptionErr] = $(params.description).optional.nullable.string().pipe(isValidDescription).$; + if (descriptionErr) return rej('invalid description param'); + if (description !== undefined) user.description = description; + + // Get 'location' parameter + const [location, locationErr] = $(params.location).optional.nullable.string().pipe(isValidLocation).$; + if (locationErr) return rej('invalid location param'); + if (location !== undefined) user.profile.location = location; + + // Get 'birthday' parameter + const [birthday, birthdayErr] = $(params.birthday).optional.nullable.string().pipe(isValidBirthday).$; + if (birthdayErr) return rej('invalid birthday param'); + if (birthday !== undefined) user.profile.birthday = birthday; + + // Get 'avatarId' parameter + const [avatarId, avatarIdErr] = $(params.avatarId).optional.id().$; + if (avatarIdErr) return rej('invalid avatarId param'); + if (avatarId) user.avatarId = avatarId; + + // Get 'bannerId' parameter + const [bannerId, bannerIdErr] = $(params.bannerId).optional.id().$; + if (bannerIdErr) return rej('invalid bannerId param'); + if (bannerId) user.bannerId = bannerId; + + // Get 'isBot' parameter + const [isBot, isBotErr] = $(params.isBot).optional.boolean().$; + if (isBotErr) return rej('invalid isBot param'); + if (isBot != null) user.isBot = isBot; + + // Get 'autoWatch' parameter + const [autoWatch, autoWatchErr] = $(params.autoWatch).optional.boolean().$; + if (autoWatchErr) return rej('invalid autoWatch param'); + if (autoWatch != null) user.settings.autoWatch = autoWatch; + + await User.update(user._id, { + $set: { + name: user.name, + description: user.description, + avatarId: user.avatarId, + bannerId: user.bannerId, + profile: user.profile, + isBot: user.isBot, + settings: user.settings + } + }); + + // Serialize + const iObj = await pack(user, user, { + detail: true, + includeSecrets: isSecure + }); + + // Send response + res(iObj); + + // Publish i updated event + event(user._id, 'i_updated', iObj); +}); diff --git a/src/server/api/endpoints/i/update_client_setting.ts b/src/server/api/endpoints/i/update_client_setting.ts new file mode 100644 index 0000000000..b0d5db5ec2 --- /dev/null +++ b/src/server/api/endpoints/i/update_client_setting.ts @@ -0,0 +1,43 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { pack } from '../../../../models/user'; +import event from '../../../../publishers/stream'; + +/** + * Update myself + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name, nameErr] = $(params.name).string().$; + if (nameErr) return rej('invalid name param'); + + // Get 'value' parameter + const [value, valueErr] = $(params.value).nullable.any().$; + if (valueErr) return rej('invalid value param'); + + const x = {}; + x[`clientSettings.${name}`] = value; + + await User.update(user._id, { + $set: x + }); + + // Serialize + user.clientSettings[name] = value; + const iObj = await pack(user, user, { + detail: true, + includeSecrets: true + }); + + // Send response + res(iObj); + + // Publish i updated event + event(user._id, 'i_updated', iObj); +}); diff --git a/src/server/api/endpoints/i/update_home.ts b/src/server/api/endpoints/i/update_home.ts new file mode 100644 index 0000000000..ce7661ede0 --- /dev/null +++ b/src/server/api/endpoints/i/update_home.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import event from '../../../../publishers/stream'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'home' parameter + const [home, homeErr] = $(params.home).optional.array().each( + $().strict.object() + .have('name', $().string()) + .have('id', $().string()) + .have('place', $().string()) + .have('data', $().object())).$; + if (homeErr) return rej('invalid home param'); + + // Get 'id' parameter + const [id, idErr] = $(params.id).optional.string().$; + if (idErr) return rej('invalid id param'); + + // Get 'data' parameter + const [data, dataErr] = $(params.data).optional.object().$; + if (dataErr) return rej('invalid data param'); + + if (home) { + await User.update(user._id, { + $set: { + 'clientSettings.home': home + } + }); + + res(); + + event(user._id, 'home_updated', { + home + }); + } else { + if (id == null && data == null) return rej('you need to set id and data params if home param unset'); + + const _home = user.clientSettings.home; + const widget = _home.find(w => w.id == id); + + if (widget == null) return rej('widget not found'); + + widget.data = data; + + await User.update(user._id, { + $set: { + 'clientSettings.home': _home + } + }); + + res(); + + event(user._id, 'home_updated', { + id, data + }); + } +}); diff --git a/src/server/api/endpoints/i/update_mobile_home.ts b/src/server/api/endpoints/i/update_mobile_home.ts new file mode 100644 index 0000000000..b710e2f330 --- /dev/null +++ b/src/server/api/endpoints/i/update_mobile_home.ts @@ -0,0 +1,59 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import event from '../../../../publishers/stream'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'home' parameter + const [home, homeErr] = $(params.home).optional.array().each( + $().strict.object() + .have('name', $().string()) + .have('id', $().string()) + .have('data', $().object())).$; + if (homeErr) return rej('invalid home param'); + + // Get 'id' parameter + const [id, idErr] = $(params.id).optional.string().$; + if (idErr) return rej('invalid id param'); + + // Get 'data' parameter + const [data, dataErr] = $(params.data).optional.object().$; + if (dataErr) return rej('invalid data param'); + + if (home) { + await User.update(user._id, { + $set: { + 'clientSettings.mobileHome': home + } + }); + + res(); + + event(user._id, 'mobile_home_updated', { + home + }); + } else { + if (id == null && data == null) return rej('you need to set id and data params if home param unset'); + + const _home = user.clientSettings.mobileHome || []; + const widget = _home.find(w => w.id == id); + + if (widget == null) return rej('widget not found'); + + widget.data = data; + + await User.update(user._id, { + $set: { + 'clientSettings.mobileHome': _home + } + }); + + res(); + + event(user._id, 'mobile_home_updated', { + id, data + }); + } +}); diff --git a/src/server/api/endpoints/messaging/history.ts b/src/server/api/endpoints/messaging/history.ts new file mode 100644 index 0000000000..e42d34f21a --- /dev/null +++ b/src/server/api/endpoints/messaging/history.ts @@ -0,0 +1,43 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import History from '../../../../models/messaging-history'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/messaging-message'; + +/** + * Show messaging history + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + const mute = await Mute.find({ + muterId: user._id, + deletedAt: { $exists: false } + }); + + // Get history + const history = await History + .find({ + userId: user._id, + partnerId: { + $nin: mute.map(m => m.muteeId) + } + }, { + limit: limit, + sort: { + updatedAt: -1 + } + }); + + // Serialize + res(await Promise.all(history.map(async h => + await pack(h.messageId, user)))); +}); diff --git a/src/server/api/endpoints/messaging/messages.ts b/src/server/api/endpoints/messaging/messages.ts new file mode 100644 index 0000000000..092eab0562 --- /dev/null +++ b/src/server/api/endpoints/messaging/messages.ts @@ -0,0 +1,102 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Message from '../../../../models/messaging-message'; +import User from '../../../../models/user'; +import { pack } from '../../../../models/messaging-message'; +import read from '../../common/read-messaging-message'; + +/** + * Get messages + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [recipientId, recipientIdErr] = $(params.userId).id().$; + if (recipientIdErr) return rej('invalid userId param'); + + // Fetch recipient + const recipient = await User.findOne({ + _id: recipientId + }, { + fields: { + _id: true + } + }); + + if (recipient === null) { + return rej('user not found'); + } + + // Get 'markAsRead' parameter + const [markAsRead = true, markAsReadErr] = $(params.markAsRead).optional.boolean().$; + if (markAsReadErr) return rej('invalid markAsRead param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + const query = { + $or: [{ + userId: user._id, + recipientId: recipient._id + }, { + userId: recipient._id, + recipientId: user._id + }] + } as any; + + const sort = { + _id: -1 + }; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const messages = await Message + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(messages.map(async message => + await pack(message, user, { + populateRecipient: false + })))); + + if (messages.length === 0) { + return; + } + + // Mark as read all + if (markAsRead) { + read(user._id, recipient._id, messages); + } +}); diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts new file mode 100644 index 0000000000..085e75e6cf --- /dev/null +++ b/src/server/api/endpoints/messaging/messages/create.ts @@ -0,0 +1,160 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Message from '../../../../../models/messaging-message'; +import { isValidText } from '../../../../../models/messaging-message'; +import History from '../../../../../models/messaging-history'; +import User from '../../../../../models/user'; +import Mute from '../../../../../models/mute'; +import DriveFile from '../../../../../models/drive-file'; +import { pack } from '../../../../../models/messaging-message'; +import publishUserStream from '../../../../../publishers/stream'; +import { publishMessagingStream, publishMessagingIndexStream } from '../../../../../publishers/stream'; +import pushSw from '../../../../../publishers/push-sw'; +import html from '../../../../../text/html'; +import parse from '../../../../../text/parse'; +import config from '../../../../../config'; + +/** + * Create a message + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [recipientId, recipientIdErr] = $(params.userId).id().$; + if (recipientIdErr) return rej('invalid userId param'); + + // Myself + if (recipientId.equals(user._id)) { + return rej('cannot send message to myself'); + } + + // Fetch recipient + const recipient = await User.findOne({ + _id: recipientId + }, { + fields: { + _id: true + } + }); + + if (recipient === null) { + return rej('user not found'); + } + + // Get 'text' parameter + const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$; + if (textErr) return rej('invalid text'); + + // Get 'fileId' parameter + const [fileId, fileIdErr] = $(params.fileId).optional.id().$; + if (fileIdErr) return rej('invalid fileId param'); + + let file = null; + if (fileId !== undefined) { + file = await DriveFile.findOne({ + _id: fileId, + 'metadata.userId': user._id + }); + + if (file === null) { + return rej('file not found'); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (text === undefined && file === null) { + return rej('text or file is required'); + } + + // メッセージを作成 + const message = await Message.insert({ + createdAt: new Date(), + fileId: file ? file._id : undefined, + recipientId: recipient._id, + text: text ? text : undefined, + textHtml: text ? html(parse(text)) : undefined, + userId: user._id, + isRead: false + }); + + // Serialize + const messageObj = await pack(message); + + // Reponse + res(messageObj); + + // 自分のストリーム + publishMessagingStream(message.userId, message.recipientId, 'message', messageObj); + publishMessagingIndexStream(message.userId, 'message', messageObj); + publishUserStream(message.userId, 'messaging_message', messageObj); + + // 相手のストリーム + publishMessagingStream(message.recipientId, message.userId, 'message', messageObj); + publishMessagingIndexStream(message.recipientId, 'message', messageObj); + publishUserStream(message.recipientId, 'messaging_message', messageObj); + + // 3秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する + setTimeout(async () => { + const freshMessage = await Message.findOne({ _id: message._id }, { isRead: true }); + if (!freshMessage.isRead) { + //#region ただしミュートされているなら発行しない + const mute = await Mute.find({ + muterId: recipient._id, + deletedAt: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.muteeId.toString()); + if (mutedUserIds.indexOf(user._id.toString()) != -1) { + return; + } + //#endregion + + publishUserStream(message.recipientId, 'unread_messaging_message', messageObj); + pushSw(message.recipientId, 'unread_messaging_message', messageObj); + } + }, 3000); + + // Register to search database + if (message.text && config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'messaging_message', + id: message._id.toString(), + body: { + text: message.text + } + }); + } + + // 履歴作成(自分) + History.update({ + userId: user._id, + partnerId: recipient._id + }, { + updatedAt: new Date(), + userId: user._id, + partnerId: recipient._id, + messageId: message._id + }, { + upsert: true + }); + + // 履歴作成(相手) + History.update({ + userId: recipient._id, + partnerId: user._id + }, { + updatedAt: new Date(), + userId: recipient._id, + partnerId: user._id, + messageId: message._id + }, { + upsert: true + }); +}); diff --git a/src/server/api/endpoints/messaging/unread.ts b/src/server/api/endpoints/messaging/unread.ts new file mode 100644 index 0000000000..30d59dd8bd --- /dev/null +++ b/src/server/api/endpoints/messaging/unread.ts @@ -0,0 +1,33 @@ +/** + * Module dependencies + */ +import Message from '../../../../models/messaging-message'; +import Mute from '../../../../models/mute'; + +/** + * Get count of unread messages + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const mute = await Mute.find({ + muterId: user._id, + deletedAt: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.muteeId); + + const count = await Message + .count({ + userId: { + $nin: mutedUserIds + }, + recipientId: user._id, + isRead: false + }); + + res({ + count: count + }); +}); diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts new file mode 100644 index 0000000000..f6a276a2b7 --- /dev/null +++ b/src/server/api/endpoints/meta.ts @@ -0,0 +1,55 @@ +/** + * Module dependencies + */ +import * as os from 'os'; +import version from '../../../version'; +import config from '../../../config'; +import Meta from '../../../models/meta'; + +/** + * @swagger + * /meta: + * note: + * summary: Show the misskey's information + * responses: + * 200: + * description: Success + * schema: + * type: object + * properties: + * maintainer: + * description: maintainer's name + * type: string + * commit: + * description: latest commit's hash + * type: string + * secure: + * description: whether the server supports secure protocols + * type: boolean + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Show core info + */ +module.exports = (params) => new Promise(async (res, rej) => { + const meta: any = (await Meta.findOne()) || {}; + + res({ + maintainer: config.maintainer, + version: version, + secure: config.https != null, + machine: os.hostname(), + os: os.platform(), + node: process.version, + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length + }, + broadcasts: meta.broadcasts + }); +}); diff --git a/src/server/api/endpoints/mute/create.ts b/src/server/api/endpoints/mute/create.ts new file mode 100644 index 0000000000..19894d07af --- /dev/null +++ b/src/server/api/endpoints/mute/create.ts @@ -0,0 +1,61 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import Mute from '../../../../models/mute'; + +/** + * Mute a user + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const muter = user; + + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).id().$; + if (userIdErr) return rej('invalid userId param'); + + // 自分自身 + if (user._id.equals(userId)) { + return rej('mutee is yourself'); + } + + // Get mutee + const mutee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + 'profile': false + } + }); + + if (mutee === null) { + return rej('user not found'); + } + + // Check if already muting + const exist = await Mute.findOne({ + muterId: muter._id, + muteeId: mutee._id, + deletedAt: { $exists: false } + }); + + if (exist !== null) { + return rej('already muting'); + } + + // Create mute + await Mute.insert({ + createdAt: new Date(), + muterId: muter._id, + muteeId: mutee._id, + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/mute/delete.ts b/src/server/api/endpoints/mute/delete.ts new file mode 100644 index 0000000000..10096352ba --- /dev/null +++ b/src/server/api/endpoints/mute/delete.ts @@ -0,0 +1,63 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import Mute from '../../../../models/mute'; + +/** + * Unmute a user + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const muter = user; + + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).id().$; + if (userIdErr) return rej('invalid userId param'); + + // Check if the mutee is yourself + if (user._id.equals(userId)) { + return rej('mutee is yourself'); + } + + // Get mutee + const mutee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + 'profile': false + } + }); + + if (mutee === null) { + return rej('user not found'); + } + + // Check not muting + const exist = await Mute.findOne({ + muterId: muter._id, + muteeId: mutee._id, + deletedAt: { $exists: false } + }); + + if (exist === null) { + return rej('already not muting'); + } + + // Delete mute + await Mute.update({ + _id: exist._id + }, { + $set: { + deletedAt: new Date() + } + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/mute/list.ts b/src/server/api/endpoints/mute/list.ts new file mode 100644 index 0000000000..bd80401445 --- /dev/null +++ b/src/server/api/endpoints/mute/list.ts @@ -0,0 +1,73 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get muted users of a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'iknow' parameter + const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$; + if (iknowErr) return rej('invalid iknow param'); + + // Get 'limit' parameter + const [limit = 30, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + if (cursorErr) return rej('invalid cursor param'); + + // Construct query + const query = { + muterId: me._id, + deletedAt: { $exists: false } + } as any; + + if (iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.muteeId = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get mutes + const mutes = await Mute + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = mutes.length === limit + 1; + if (inStock) { + mutes.pop(); + } + + // Serialize + const users = await Promise.all(mutes.map(async m => + await pack(m.muteeId, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? mutes[mutes.length - 1]._id : null, + }); +}); diff --git a/src/server/api/endpoints/my/apps.ts b/src/server/api/endpoints/my/apps.ts new file mode 100644 index 0000000000..2a3f8bcd7a --- /dev/null +++ b/src/server/api/endpoints/my/apps.ts @@ -0,0 +1,40 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import App, { pack } from '../../../../models/app'; + +/** + * Get my apps + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + const query = { + userId: user._id + }; + + // Execute query + const apps = await App + .find(query, { + limit: limit, + skip: offset, + sort: { + _id: -1 + } + }); + + // Reply + res(await Promise.all(apps.map(async app => + await pack(app)))); +}); diff --git a/src/server/api/endpoints/notes.ts b/src/server/api/endpoints/notes.ts new file mode 100644 index 0000000000..a70ac0588f --- /dev/null +++ b/src/server/api/endpoints/notes.ts @@ -0,0 +1,94 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Note, { pack } from '../../../models/note'; + +/** + * Get all notes + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'reply' parameter + const [reply, replyErr] = $(params.reply).optional.boolean().$; + if (replyErr) return rej('invalid reply param'); + + // Get 'renote' parameter + const [renote, renoteErr] = $(params.renote).optional.boolean().$; + if (renoteErr) return rej('invalid renote param'); + + // Get 'media' parameter + const [media, mediaErr] = $(params.media).optional.boolean().$; + if (mediaErr) return rej('invalid media param'); + + // Get 'poll' parameter + const [poll, pollErr] = $(params.poll).optional.boolean().$; + if (pollErr) return rej('invalid poll param'); + + // Get 'bot' parameter + //const [bot, botErr] = $(params.bot).optional.boolean().$; + //if (botErr) return rej('invalid bot param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = {} as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + if (reply != undefined) { + query.replyId = reply ? { $exists: true, $ne: null } : null; + } + + if (renote != undefined) { + query.renoteId = renote ? { $exists: true, $ne: null } : null; + } + + if (media != undefined) { + query.mediaIds = media ? { $exists: true, $ne: null } : []; + } + + if (poll != undefined) { + query.poll = poll ? { $exists: true, $ne: null } : null; + } + + // TODO + //if (bot != undefined) { + // query.isBot = bot; + //} + + // Issue query + const notes = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(notes.map(note => pack(note)))); +}); diff --git a/src/server/api/endpoints/notes/context.ts b/src/server/api/endpoints/notes/context.ts new file mode 100644 index 0000000000..2caf742d26 --- /dev/null +++ b/src/server/api/endpoints/notes/context.ts @@ -0,0 +1,63 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Note, { pack } from '../../../../models/note'; + +/** + * Show a context of a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $(params.noteId).id().$; + if (noteIdErr) return rej('invalid noteId param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Lookup note + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + const context = []; + let i = 0; + + async function get(id) { + i++; + const p = await Note.findOne({ _id: id }); + + if (i > offset) { + context.push(p); + } + + if (context.length == limit) { + return; + } + + if (p.replyId) { + await get(p.replyId); + } + } + + if (note.replyId) { + await get(note.replyId); + } + + // Serialize + res(await Promise.all(context.map(async note => + await pack(note, user)))); +}); diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts new file mode 100644 index 0000000000..7e79912b1b --- /dev/null +++ b/src/server/api/endpoints/notes/create.ts @@ -0,0 +1,251 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import deepEqual = require('deep-equal'); +import Note, { INote, isValidText, isValidCw, pack } from '../../../../models/note'; +import { ILocalUser } from '../../../../models/user'; +import Channel, { IChannel } from '../../../../models/channel'; +import DriveFile from '../../../../models/drive-file'; +import create from '../../../../services/note/create'; +import { IApp } from '../../../../models/app'; + +/** + * Create a note + * + * @param {any} params + * @param {any} user + * @param {any} app + * @return {Promise<any>} + */ +module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => { + // Get 'visibility' parameter + const [visibility = 'public', visibilityErr] = $(params.visibility).optional.string().or(['public', 'unlisted', 'private', 'direct']).$; + if (visibilityErr) return rej('invalid visibility'); + + // Get 'text' parameter + const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$; + if (textErr) return rej('invalid text'); + + // Get 'cw' parameter + const [cw, cwErr] = $(params.cw).optional.string().pipe(isValidCw).$; + if (cwErr) return rej('invalid cw'); + + // Get 'viaMobile' parameter + const [viaMobile = false, viaMobileErr] = $(params.viaMobile).optional.boolean().$; + if (viaMobileErr) return rej('invalid viaMobile'); + + // Get 'tags' parameter + const [tags = [], tagsErr] = $(params.tags).optional.array('string').unique().eachQ(t => t.range(1, 32)).$; + if (tagsErr) return rej('invalid tags'); + + // Get 'geo' parameter + const [geo, geoErr] = $(params.geo).optional.nullable.strict.object() + .have('coordinates', $().array().length(2) + .item(0, $().number().range(-180, 180)) + .item(1, $().number().range(-90, 90))) + .have('altitude', $().nullable.number()) + .have('accuracy', $().nullable.number()) + .have('altitudeAccuracy', $().nullable.number()) + .have('heading', $().nullable.number().range(0, 360)) + .have('speed', $().nullable.number()) + .$; + if (geoErr) return rej('invalid geo'); + + // Get 'mediaIds' parameter + const [mediaIds, mediaIdsErr] = $(params.mediaIds).optional.array('id').unique().range(1, 4).$; + if (mediaIdsErr) return rej('invalid mediaIds'); + + let files = []; + if (mediaIds !== undefined) { + // Fetch files + // forEach だと途中でエラーなどがあっても return できないので + // 敢えて for を使っています。 + for (const mediaId of mediaIds) { + // Fetch file + // SELECT _id + const entity = await DriveFile.findOne({ + _id: mediaId, + 'metadata.userId': user._id + }); + + if (entity === null) { + return rej('file not found'); + } else { + files.push(entity); + } + } + } else { + files = null; + } + + // Get 'renoteId' parameter + const [renoteId, renoteIdErr] = $(params.renoteId).optional.id().$; + if (renoteIdErr) return rej('invalid renoteId'); + + let renote: INote = null; + let isQuote = false; + if (renoteId !== undefined) { + // Fetch renote to note + renote = await Note.findOne({ + _id: renoteId + }); + + if (renote == null) { + return rej('renoteee is not found'); + } else if (renote.renoteId && !renote.text && !renote.mediaIds) { + return rej('cannot renote to renote'); + } + + // Fetch recently note + const latestNote = await Note.findOne({ + userId: user._id + }, { + sort: { + _id: -1 + } + }); + + isQuote = text != null || files != null; + + // 直近と同じRenote対象かつ引用じゃなかったらエラー + if (latestNote && + latestNote.renoteId && + latestNote.renoteId.equals(renote._id) && + !isQuote) { + return rej('cannot renote same note that already reposted in your latest note'); + } + + // 直近がRenote対象かつ引用じゃなかったらエラー + if (latestNote && + latestNote._id.equals(renote._id) && + !isQuote) { + return rej('cannot renote your latest note'); + } + } + + // Get 'replyId' parameter + const [replyId, replyIdErr] = $(params.replyId).optional.id().$; + if (replyIdErr) return rej('invalid replyId'); + + let reply: INote = null; + if (replyId !== undefined) { + // Fetch reply + reply = await Note.findOne({ + _id: replyId + }); + + if (reply === null) { + return rej('in reply to note is not found'); + } + + // 返信対象が引用でないRenoteだったらエラー + if (reply.renoteId && !reply.text && !reply.mediaIds) { + return rej('cannot reply to renote'); + } + } + + // Get 'channelId' parameter + const [channelId, channelIdErr] = $(params.channelId).optional.id().$; + if (channelIdErr) return rej('invalid channelId'); + + let channel: IChannel = null; + if (channelId !== undefined) { + // Fetch channel + channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + // 返信対象の投稿がこのチャンネルじゃなかったらダメ + if (reply && !channelId.equals(reply.channelId)) { + return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません'); + } + + // Renote対象の投稿がこのチャンネルじゃなかったらダメ + if (renote && !channelId.equals(renote.channelId)) { + return rej('チャンネル内部からチャンネル外部の投稿をRenoteすることはできません'); + } + + // 引用ではないRenoteはダメ + if (renote && !isQuote) { + return rej('チャンネル内部では引用ではないRenoteをすることはできません'); + } + } else { + // 返信対象の投稿がチャンネルへの投稿だったらダメ + if (reply && reply.channelId != null) { + return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません'); + } + + // Renote対象の投稿がチャンネルへの投稿だったらダメ + if (renote && renote.channelId != null) { + return rej('チャンネル外部からチャンネル内部の投稿をRenoteすることはできません'); + } + } + + // Get 'poll' parameter + const [poll, pollErr] = $(params.poll).optional.strict.object() + .have('choices', $().array('string') + .unique() + .range(2, 10) + .each(c => c.length > 0 && c.length < 50)) + .$; + if (pollErr) return rej('invalid poll'); + + if (poll) { + (poll as any).choices = (poll as any).choices.map((choice, i) => ({ + id: i, // IDを付与 + text: choice.trim(), + votes: 0 + })); + } + + // テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー + if (text === undefined && files === null && renote === null && poll === undefined) { + return rej('text, mediaIds, renoteId or poll is required'); + } + + // 直近の投稿と重複してたらエラー + // TODO: 直近の投稿が一日前くらいなら重複とは見なさない + if (user.latestNote) { + if (deepEqual({ + text: user.latestNote.text, + reply: user.latestNote.replyId ? user.latestNote.replyId.toString() : null, + renote: user.latestNote.renoteId ? user.latestNote.renoteId.toString() : null, + mediaIds: (user.latestNote.mediaIds || []).map(id => id.toString()) + }, { + text: text, + reply: reply ? reply._id.toString() : null, + renote: renote ? renote._id.toString() : null, + mediaIds: (files || []).map(file => file._id.toString()) + })) { + return rej('duplicate'); + } + } + + // 投稿を作成 + const note = await create(user, { + createdAt: new Date(), + media: files, + poll: poll, + text: text, + reply, + renote, + cw: cw, + tags: tags, + app: app, + viaMobile: viaMobile, + visibility, + geo + }); + + const noteObj = await pack(note, user); + + // Reponse + res({ + createdNote: noteObj + }); +}); diff --git a/src/server/api/endpoints/notes/favorites/create.ts b/src/server/api/endpoints/notes/favorites/create.ts new file mode 100644 index 0000000000..c8e7f52426 --- /dev/null +++ b/src/server/api/endpoints/notes/favorites/create.ts @@ -0,0 +1,48 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Favorite from '../../../../../models/favorite'; +import Note from '../../../../../models/note'; + +/** + * Favorite a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $(params.noteId).id().$; + if (noteIdErr) return rej('invalid noteId param'); + + // Get favoritee + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // if already favorited + const exist = await Favorite.findOne({ + noteId: note._id, + userId: user._id + }); + + if (exist !== null) { + return rej('already favorited'); + } + + // Create favorite + await Favorite.insert({ + createdAt: new Date(), + noteId: note._id, + userId: user._id + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/notes/favorites/delete.ts b/src/server/api/endpoints/notes/favorites/delete.ts new file mode 100644 index 0000000000..92aceb343b --- /dev/null +++ b/src/server/api/endpoints/notes/favorites/delete.ts @@ -0,0 +1,46 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Favorite from '../../../../../models/favorite'; +import Note from '../../../../../models/note'; + +/** + * Unfavorite a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $(params.noteId).id().$; + if (noteIdErr) return rej('invalid noteId param'); + + // Get favoritee + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // if already favorited + const exist = await Favorite.findOne({ + noteId: note._id, + userId: user._id + }); + + if (exist === null) { + return rej('already not favorited'); + } + + // Delete favorite + await Favorite.remove({ + _id: exist._id + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts new file mode 100644 index 0000000000..c507acbaec --- /dev/null +++ b/src/server/api/endpoints/notes/mentions.ts @@ -0,0 +1,78 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Note from '../../../../models/note'; +import getFriends from '../../common/get-friends'; +import { pack } from '../../../../models/note'; + +/** + * Get mentions of myself + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'following' parameter + const [following = false, followingError] = + $(params.following).optional.boolean().$; + if (followingError) return rej('invalid following param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Construct query + const query = { + mentions: user._id + } as any; + + const sort = { + _id: -1 + }; + + if (following) { + const followingIds = await getFriends(user._id); + + query.userId = { + $in: followingIds + }; + } + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const mentions = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(mentions.map(async mention => + await pack(mention, user) + ))); +}); diff --git a/src/server/api/endpoints/notes/polls/recommendation.ts b/src/server/api/endpoints/notes/polls/recommendation.ts new file mode 100644 index 0000000000..cb530ea2cf --- /dev/null +++ b/src/server/api/endpoints/notes/polls/recommendation.ts @@ -0,0 +1,59 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Vote from '../../../../../models/poll-vote'; +import Note, { pack } from '../../../../../models/note'; + +/** + * Get recommended polls + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get votes + const votes = await Vote.find({ + userId: user._id + }, { + fields: { + _id: false, + noteId: true + } + }); + + const nin = votes && votes.length != 0 ? votes.map(v => v.noteId) : []; + + const notes = await Note + .find({ + _id: { + $nin: nin + }, + userId: { + $ne: user._id + }, + poll: { + $exists: true, + $ne: null + } + }, { + limit: limit, + skip: offset, + sort: { + _id: -1 + } + }); + + // Serialize + res(await Promise.all(notes.map(async note => + await pack(note, user, { detail: true })))); +}); diff --git a/src/server/api/endpoints/notes/polls/vote.ts b/src/server/api/endpoints/notes/polls/vote.ts new file mode 100644 index 0000000000..03d94da60d --- /dev/null +++ b/src/server/api/endpoints/notes/polls/vote.ts @@ -0,0 +1,115 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Vote from '../../../../../models/poll-vote'; +import Note from '../../../../../models/note'; +import Watching from '../../../../../models/note-watching'; +import watch from '../../../../../services/note/watch'; +import { publishNoteStream } from '../../../../../publishers/stream'; +import notify from '../../../../../publishers/notify'; + +/** + * Vote poll of a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $(params.noteId).id().$; + if (noteIdErr) return rej('invalid noteId param'); + + // Get votee + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + if (note.poll == null) { + return rej('poll not found'); + } + + // Get 'choice' parameter + const [choice, choiceError] = + $(params.choice).number() + .pipe(c => note.poll.choices.some(x => x.id == c)) + .$; + if (choiceError) return rej('invalid choice param'); + + // if already voted + const exist = await Vote.findOne({ + noteId: note._id, + userId: user._id + }); + + if (exist !== null) { + return rej('already voted'); + } + + // Create vote + await Vote.insert({ + createdAt: new Date(), + noteId: note._id, + userId: user._id, + choice: choice + }); + + // Send response + res(); + + const inc = {}; + inc[`poll.choices.${findWithAttr(note.poll.choices, 'id', choice)}.votes`] = 1; + + // Increment votes count + await Note.update({ _id: note._id }, { + $inc: inc + }); + + publishNoteStream(note._id, 'poll_voted'); + + // Notify + notify(note.userId, user._id, 'poll_vote', { + noteId: note._id, + choice: choice + }); + + // Fetch watchers + Watching + .find({ + noteId: note._id, + userId: { $ne: user._id }, + // 削除されたドキュメントは除く + deletedAt: { $exists: false } + }, { + fields: { + userId: true + } + }) + .then(watchers => { + watchers.forEach(watcher => { + notify(watcher.userId, user._id, 'poll_vote', { + noteId: note._id, + choice: choice + }); + }); + }); + + // この投稿をWatchする + if (user.settings.autoWatch !== false) { + watch(user._id, note); + } +}); + +function findWithAttr(array, attr, value) { + for (let i = 0; i < array.length; i += 1) { + if (array[i][attr] === value) { + return i; + } + } + return -1; +} diff --git a/src/server/api/endpoints/notes/reactions.ts b/src/server/api/endpoints/notes/reactions.ts new file mode 100644 index 0000000000..bbff97bb0a --- /dev/null +++ b/src/server/api/endpoints/notes/reactions.ts @@ -0,0 +1,57 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Note from '../../../../models/note'; +import Reaction, { pack } from '../../../../models/note-reaction'; + +/** + * Show reactions of a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $(params.noteId).id().$; + if (noteIdErr) return rej('invalid noteId param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$; + if (sortError) return rej('invalid sort param'); + + // Lookup note + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // Issue query + const reactions = await Reaction + .find({ + noteId: note._id, + deletedAt: { $exists: false } + }, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }); + + // Serialize + res(await Promise.all(reactions.map(async reaction => + await pack(reaction, user)))); +}); diff --git a/src/server/api/endpoints/notes/reactions/create.ts b/src/server/api/endpoints/notes/reactions/create.ts new file mode 100644 index 0000000000..c80c5416b1 --- /dev/null +++ b/src/server/api/endpoints/notes/reactions/create.ts @@ -0,0 +1,46 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Note from '../../../../../models/note'; +import create from '../../../../../services/note/reaction/create'; + +/** + * React to a note + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $(params.noteId).id().$; + if (noteIdErr) return rej('invalid noteId param'); + + // Get 'reaction' parameter + const [reaction, reactionErr] = $(params.reaction).string().or([ + 'like', + 'love', + 'laugh', + 'hmm', + 'surprise', + 'congrats', + 'angry', + 'confused', + 'pudding' + ]).$; + if (reactionErr) return rej('invalid reaction param'); + + // Fetch reactee + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + try { + await create(user, note, reaction); + } catch (e) { + rej(e); + } + + res(); +}); diff --git a/src/server/api/endpoints/notes/reactions/delete.ts b/src/server/api/endpoints/notes/reactions/delete.ts new file mode 100644 index 0000000000..b5d738b8ff --- /dev/null +++ b/src/server/api/endpoints/notes/reactions/delete.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Reaction from '../../../../../models/note-reaction'; +import Note from '../../../../../models/note'; +// import event from '../../../publishers/stream'; + +/** + * Unreact to a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $(params.noteId).id().$; + if (noteIdErr) return rej('invalid noteId param'); + + // Fetch unreactee + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // if already unreacted + const exist = await Reaction.findOne({ + noteId: note._id, + userId: user._id, + deletedAt: { $exists: false } + }); + + if (exist === null) { + return rej('never reacted'); + } + + // Delete reaction + await Reaction.update({ + _id: exist._id + }, { + $set: { + deletedAt: new Date() + } + }); + + // Send response + res(); + + const dec = {}; + dec[`reactionCounts.${exist.reaction}`] = -1; + + // Decrement reactions count + Note.update({ _id: note._id }, { + $inc: dec + }); +}); diff --git a/src/server/api/endpoints/notes/replies.ts b/src/server/api/endpoints/notes/replies.ts new file mode 100644 index 0000000000..88d9ff329a --- /dev/null +++ b/src/server/api/endpoints/notes/replies.ts @@ -0,0 +1,53 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Note, { pack } from '../../../../models/note'; + +/** + * Show a replies of a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $(params.noteId).id().$; + if (noteIdErr) return rej('invalid noteId param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$; + if (sortError) return rej('invalid sort param'); + + // Lookup note + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // Issue query + const replies = await Note + .find({ replyId: note._id }, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }); + + // Serialize + res(await Promise.all(replies.map(async note => + await pack(note, user)))); +}); diff --git a/src/server/api/endpoints/notes/reposts.ts b/src/server/api/endpoints/notes/reposts.ts new file mode 100644 index 0000000000..9dfc2c3cb5 --- /dev/null +++ b/src/server/api/endpoints/notes/reposts.ts @@ -0,0 +1,73 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Note, { pack } from '../../../../models/note'; + +/** + * Show a renotes of a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $(params.noteId).id().$; + if (noteIdErr) return rej('invalid noteId param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + // Lookup note + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = { + renoteId: note._id + } as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const renotes = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(renotes.map(async note => + await pack(note, user)))); +}); diff --git a/src/server/api/endpoints/notes/search.ts b/src/server/api/endpoints/notes/search.ts new file mode 100644 index 0000000000..bfa17b000e --- /dev/null +++ b/src/server/api/endpoints/notes/search.ts @@ -0,0 +1,364 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +const escapeRegexp = require('escape-regexp'); +import Note from '../../../../models/note'; +import User from '../../../../models/user'; +import Mute from '../../../../models/mute'; +import getFriends from '../../common/get-friends'; +import { pack } from '../../../../models/note'; + +/** + * Search a note + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'text' parameter + const [text, textError] = $(params.text).optional.string().$; + if (textError) return rej('invalid text param'); + + // Get 'includeUserIds' parameter + const [includeUserIds = [], includeUserIdsErr] = $(params.includeUserIds).optional.array('id').$; + if (includeUserIdsErr) return rej('invalid includeUserIds param'); + + // Get 'excludeUserIds' parameter + const [excludeUserIds = [], excludeUserIdsErr] = $(params.excludeUserIds).optional.array('id').$; + if (excludeUserIdsErr) return rej('invalid excludeUserIds param'); + + // Get 'includeUserUsernames' parameter + const [includeUserUsernames = [], includeUserUsernamesErr] = $(params.includeUserUsernames).optional.array('string').$; + if (includeUserUsernamesErr) return rej('invalid includeUserUsernames param'); + + // Get 'excludeUserUsernames' parameter + const [excludeUserUsernames = [], excludeUserUsernamesErr] = $(params.excludeUserUsernames).optional.array('string').$; + if (excludeUserUsernamesErr) return rej('invalid excludeUserUsernames param'); + + // Get 'following' parameter + const [following = null, followingErr] = $(params.following).optional.nullable.boolean().$; + if (followingErr) return rej('invalid following param'); + + // Get 'mute' parameter + const [mute = 'mute_all', muteErr] = $(params.mute).optional.string().$; + if (muteErr) return rej('invalid mute param'); + + // Get 'reply' parameter + const [reply = null, replyErr] = $(params.reply).optional.nullable.boolean().$; + if (replyErr) return rej('invalid reply param'); + + // Get 'renote' parameter + const [renote = null, renoteErr] = $(params.renote).optional.nullable.boolean().$; + if (renoteErr) return rej('invalid renote param'); + + // Get 'media' parameter + const [media = null, mediaErr] = $(params.media).optional.nullable.boolean().$; + if (mediaErr) return rej('invalid media param'); + + // Get 'poll' parameter + const [poll = null, pollErr] = $(params.poll).optional.nullable.boolean().$; + if (pollErr) return rej('invalid poll param'); + + // Get 'sinceDate' parameter + const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$; + if (sinceDateErr) throw 'invalid sinceDate param'; + + // Get 'untilDate' parameter + const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$; + if (untilDateErr) throw 'invalid untilDate param'; + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 30).$; + if (limitErr) return rej('invalid limit param'); + + let includeUsers = includeUserIds; + if (includeUserUsernames != null) { + const ids = (await Promise.all(includeUserUsernames.map(async (username) => { + const _user = await User.findOne({ + usernameLower: username.toLowerCase() + }); + return _user ? _user._id : null; + }))).filter(id => id != null); + includeUsers = includeUsers.concat(ids); + } + + let excludeUsers = excludeUserIds; + if (excludeUserUsernames != null) { + const ids = (await Promise.all(excludeUserUsernames.map(async (username) => { + const _user = await User.findOne({ + usernameLower: username.toLowerCase() + }); + return _user ? _user._id : null; + }))).filter(id => id != null); + excludeUsers = excludeUsers.concat(ids); + } + + search(res, rej, me, text, includeUsers, excludeUsers, following, + mute, reply, renote, media, poll, sinceDate, untilDate, offset, limit); +}); + +async function search( + res, rej, me, text, includeUserIds, excludeUserIds, following, + mute, reply, renote, media, poll, sinceDate, untilDate, offset, max) { + + let q: any = { + $and: [] + }; + + const push = x => q.$and.push(x); + + if (text) { + // 完全一致検索 + if (/"""(.+?)"""/.test(text)) { + const x = text.match(/"""(.+?)"""/)[1]; + push({ + text: x + }); + } else { + const tags = text.split(' ').filter(x => x[0] == '#'); + if (tags) { + push({ + $and: tags.map(x => ({ + tags: x + })) + }); + } + + push({ + $and: text.split(' ').map(x => ({ + // キーワードが-で始まる場合そのキーワードを除外する + text: x[0] == '-' ? { + $not: new RegExp(escapeRegexp(x.substr(1))) + } : new RegExp(escapeRegexp(x)) + })) + }); + } + } + + if (includeUserIds && includeUserIds.length != 0) { + push({ + userId: { + $in: includeUserIds + } + }); + } else if (excludeUserIds && excludeUserIds.length != 0) { + push({ + userId: { + $nin: excludeUserIds + } + }); + } + + if (following != null && me != null) { + const ids = await getFriends(me._id, false); + push({ + userId: following ? { + $in: ids + } : { + $nin: ids.concat(me._id) + } + }); + } + + if (me != null) { + const mutes = await Mute.find({ + muterId: me._id, + deletedAt: { $exists: false } + }); + const mutedUserIds = mutes.map(m => m.muteeId); + + switch (mute) { + case 'mute_all': + push({ + userId: { + $nin: mutedUserIds + }, + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + } + }); + break; + case 'mute_related': + push({ + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + } + }); + break; + case 'mute_direct': + push({ + userId: { + $nin: mutedUserIds + } + }); + break; + case 'direct_only': + push({ + userId: { + $in: mutedUserIds + } + }); + break; + case 'related_only': + push({ + $or: [{ + '_reply.userId': { + $in: mutedUserIds + } + }, { + '_renote.userId': { + $in: mutedUserIds + } + }] + }); + break; + case 'all_only': + push({ + $or: [{ + userId: { + $in: mutedUserIds + } + }, { + '_reply.userId': { + $in: mutedUserIds + } + }, { + '_renote.userId': { + $in: mutedUserIds + } + }] + }); + break; + } + } + + if (reply != null) { + if (reply) { + push({ + replyId: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + replyId: { + $exists: false + } + }, { + replyId: null + }] + }); + } + } + + if (renote != null) { + if (renote) { + push({ + renoteId: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + renoteId: { + $exists: false + } + }, { + renoteId: null + }] + }); + } + } + + if (media != null) { + if (media) { + push({ + mediaIds: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + mediaIds: { + $exists: false + } + }, { + mediaIds: null + }] + }); + } + } + + if (poll != null) { + if (poll) { + push({ + poll: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + poll: { + $exists: false + } + }, { + poll: null + }] + }); + } + } + + if (sinceDate) { + push({ + createdAt: { + $gt: new Date(sinceDate) + } + }); + } + + if (untilDate) { + push({ + createdAt: { + $lt: new Date(untilDate) + } + }); + } + + if (q.$and.length == 0) { + q = {}; + } + + // Search notes + const notes = await Note + .find(q, { + sort: { + _id: -1 + }, + limit: max, + skip: offset + }); + + // Serialize + res(await Promise.all(notes.map(async note => + await pack(note, me)))); +} diff --git a/src/server/api/endpoints/notes/show.ts b/src/server/api/endpoints/notes/show.ts new file mode 100644 index 0000000000..67cdc3038b --- /dev/null +++ b/src/server/api/endpoints/notes/show.ts @@ -0,0 +1,32 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Note, { pack } from '../../../../models/note'; + +/** + * Show a note + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'noteId' parameter + const [noteId, noteIdErr] = $(params.noteId).id().$; + if (noteIdErr) return rej('invalid noteId param'); + + // Get note + const note = await Note.findOne({ + _id: noteId + }); + + if (note === null) { + return rej('note not found'); + } + + // Serialize + res(await pack(note, user, { + detail: true + })); +}); diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts new file mode 100644 index 0000000000..5263cfb2aa --- /dev/null +++ b/src/server/api/endpoints/notes/timeline.ts @@ -0,0 +1,132 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import rap from '@prezzemolo/rap'; +import Note from '../../../../models/note'; +import Mute from '../../../../models/mute'; +import ChannelWatching from '../../../../models/channel-watching'; +import getFriends from '../../common/get-friends'; +import { pack } from '../../../../models/note'; + +/** + * Get timeline of myself + * + * @param {any} params + * @param {any} user + * @param {any} app + * @return {Promise<any>} + */ +module.exports = async (params, user, app) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) throw 'invalid limit param'; + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) throw 'invalid sinceId param'; + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) throw 'invalid untilId param'; + + // Get 'sinceDate' parameter + const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$; + if (sinceDateErr) throw 'invalid sinceDate param'; + + // Get 'untilDate' parameter + const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$; + if (untilDateErr) throw 'invalid untilDate param'; + + // Check if only one of sinceId, untilId, sinceDate, untilDate specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; + } + + const { followingIds, watchingChannelIds, mutedUserIds } = await rap({ + // ID list of the user itself and other users who the user follows + followingIds: getFriends(user._id), + + // Watchしているチャンネルを取得 + watchingChannelIds: ChannelWatching.find({ + userId: user._id, + // 削除されたドキュメントは除く + deletedAt: { $exists: false } + }).then(watches => watches.map(w => w.channelId)), + + // ミュートしているユーザーを取得 + mutedUserIds: Mute.find({ + muterId: user._id, + // 削除されたドキュメントは除く + deletedAt: { $exists: false } + }).then(ms => ms.map(m => m.muteeId)) + }); + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + $or: [{ + // フォローしている人のタイムラインへの投稿 + userId: { + $in: followingIds + }, + // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る + $or: [{ + channelId: { + $exists: false + } + }, { + channelId: null + }] + }, { + // Watchしているチャンネルへの投稿 + channelId: { + $in: watchingChannelIds + } + }], + // mute + userId: { + $nin: mutedUserIds + }, + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + }, + } as any; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } else if (sinceDate) { + sort._id = 1; + query.createdAt = { + $gt: new Date(sinceDate) + }; + } else if (untilDate) { + query.createdAt = { + $lt: new Date(untilDate) + }; + } + //#endregion + + // Issue query + const timeline = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + return await Promise.all(timeline.map(note => pack(note, user))); +}; diff --git a/src/server/api/endpoints/notes/trend.ts b/src/server/api/endpoints/notes/trend.ts new file mode 100644 index 0000000000..48ecd5b843 --- /dev/null +++ b/src/server/api/endpoints/notes/trend.ts @@ -0,0 +1,79 @@ +/** + * Module dependencies + */ +const ms = require('ms'); +import $ from 'cafy'; +import Note, { pack } from '../../../../models/note'; + +/** + * Get trend notes + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'reply' parameter + const [reply, replyErr] = $(params.reply).optional.boolean().$; + if (replyErr) return rej('invalid reply param'); + + // Get 'renote' parameter + const [renote, renoteErr] = $(params.renote).optional.boolean().$; + if (renoteErr) return rej('invalid renote param'); + + // Get 'media' parameter + const [media, mediaErr] = $(params.media).optional.boolean().$; + if (mediaErr) return rej('invalid media param'); + + // Get 'poll' parameter + const [poll, pollErr] = $(params.poll).optional.boolean().$; + if (pollErr) return rej('invalid poll param'); + + const query = { + createdAt: { + $gte: new Date(Date.now() - ms('1days')) + }, + renoteCount: { + $gt: 0 + } + } as any; + + if (reply != undefined) { + query.replyId = reply ? { $exists: true, $ne: null } : null; + } + + if (renote != undefined) { + query.renoteId = renote ? { $exists: true, $ne: null } : null; + } + + if (media != undefined) { + query.mediaIds = media ? { $exists: true, $ne: null } : null; + } + + if (poll != undefined) { + query.poll = poll ? { $exists: true, $ne: null } : null; + } + + // Issue query + const notes = await Note + .find(query, { + limit: limit, + skip: offset, + sort: { + renoteCount: -1, + _id: -1 + } + }); + + // Serialize + res(await Promise.all(notes.map(async note => + await pack(note, user, { detail: true })))); +}); diff --git a/src/server/api/endpoints/notifications/get_unread_count.ts b/src/server/api/endpoints/notifications/get_unread_count.ts new file mode 100644 index 0000000000..283ecd63b1 --- /dev/null +++ b/src/server/api/endpoints/notifications/get_unread_count.ts @@ -0,0 +1,33 @@ +/** + * Module dependencies + */ +import Notification from '../../../../models/notification'; +import Mute from '../../../../models/mute'; + +/** + * Get count of unread notifications + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const mute = await Mute.find({ + muterId: user._id, + deletedAt: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.muteeId); + + const count = await Notification + .count({ + notifieeId: user._id, + notifierId: { + $nin: mutedUserIds + }, + isRead: false + }); + + res({ + count: count + }); +}); diff --git a/src/server/api/endpoints/notifications/mark_as_read_all.ts b/src/server/api/endpoints/notifications/mark_as_read_all.ts new file mode 100644 index 0000000000..01c9145837 --- /dev/null +++ b/src/server/api/endpoints/notifications/mark_as_read_all.ts @@ -0,0 +1,32 @@ +/** + * Module dependencies + */ +import Notification from '../../../../models/notification'; +import event from '../../../../publishers/stream'; + +/** + * Mark as read all notifications + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Update documents + await Notification.update({ + notifieeId: user._id, + isRead: false + }, { + $set: { + isRead: true + } + }, { + multi: true + }); + + // Response + res(); + + // 全ての通知を読みましたよというイベントを発行 + event(user._id, 'read_all_notifications'); +}); diff --git a/src/server/api/endpoints/othello/games.ts b/src/server/api/endpoints/othello/games.ts new file mode 100644 index 0000000000..d05c1c2585 --- /dev/null +++ b/src/server/api/endpoints/othello/games.ts @@ -0,0 +1,62 @@ +import $ from 'cafy'; +import OthelloGame, { pack } from '../../../../models/othello-game'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'my' parameter + const [my = false, myErr] = $(params.my).optional.boolean().$; + if (myErr) return rej('invalid my param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + const q: any = my ? { + isStarted: true, + $or: [{ + user1Id: user._id + }, { + user2Id: user._id + }] + } : { + isStarted: true + }; + + const sort = { + _id: -1 + }; + + if (sinceId) { + sort._id = 1; + q._id = { + $gt: sinceId + }; + } else if (untilId) { + q._id = { + $lt: untilId + }; + } + + // Fetch games + const games = await OthelloGame.find(q, { + sort, + limit + }); + + // Reponse + res(Promise.all(games.map(async (g) => await pack(g, user, { + detail: false + })))); +}); diff --git a/src/server/api/endpoints/othello/games/show.ts b/src/server/api/endpoints/othello/games/show.ts new file mode 100644 index 0000000000..dd886936d4 --- /dev/null +++ b/src/server/api/endpoints/othello/games/show.ts @@ -0,0 +1,32 @@ +import $ from 'cafy'; +import OthelloGame, { pack } from '../../../../../models/othello-game'; +import Othello from '../../../../../othello/core'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'gameId' parameter + const [gameId, gameIdErr] = $(params.gameId).id().$; + if (gameIdErr) return rej('invalid gameId param'); + + const game = await OthelloGame.findOne({ _id: gameId }); + + if (game == null) { + return rej('game not found'); + } + + const o = new Othello(game.settings.map, { + isLlotheo: game.settings.isLlotheo, + canPutEverywhere: game.settings.canPutEverywhere, + loopedBoard: game.settings.loopedBoard + }); + + game.logs.forEach(log => { + o.put(log.color, log.pos); + }); + + const packed = await pack(game, user); + + res(Object.assign({ + board: o.board, + turn: o.turn + }, packed)); +}); diff --git a/src/server/api/endpoints/othello/invitations.ts b/src/server/api/endpoints/othello/invitations.ts new file mode 100644 index 0000000000..4761537614 --- /dev/null +++ b/src/server/api/endpoints/othello/invitations.ts @@ -0,0 +1,15 @@ +import Matching, { pack as packMatching } from '../../../../models/othello-matching'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + // Find session + const invitations = await Matching.find({ + childId: user._id + }, { + sort: { + _id: -1 + } + }); + + // Reponse + res(Promise.all(invitations.map(async (i) => await packMatching(i, user)))); +}); diff --git a/src/server/api/endpoints/othello/match.ts b/src/server/api/endpoints/othello/match.ts new file mode 100644 index 0000000000..d9274f8f9c --- /dev/null +++ b/src/server/api/endpoints/othello/match.ts @@ -0,0 +1,95 @@ +import $ from 'cafy'; +import Matching, { pack as packMatching } from '../../../../models/othello-matching'; +import OthelloGame, { pack as packGame } from '../../../../models/othello-game'; +import User from '../../../../models/user'; +import publishUserStream, { publishOthelloStream } from '../../../../publishers/stream'; +import { eighteight } from '../../../../othello/maps'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [childId, childIdErr] = $(params.userId).id().$; + if (childIdErr) return rej('invalid userId param'); + + // Myself + if (childId.equals(user._id)) { + return rej('invalid userId param'); + } + + // Find session + const exist = await Matching.findOne({ + parentId: childId, + childId: user._id + }); + + if (exist) { + // Destroy session + Matching.remove({ + _id: exist._id + }); + + // Create game + const game = await OthelloGame.insert({ + createdAt: new Date(), + user1Id: exist.parentId, + user2Id: user._id, + user1Accepted: false, + user2Accepted: false, + isStarted: false, + isEnded: false, + logs: [], + settings: { + map: eighteight.data, + bw: 'random', + isLlotheo: false + } + }); + + // Reponse + res(await packGame(game, user)); + + publishOthelloStream(exist.parentId, 'matched', await packGame(game, exist.parentId)); + + const other = await Matching.count({ + childId: user._id + }); + + if (other == 0) { + publishUserStream(user._id, 'othello_no_invites'); + } + } else { + // Fetch child + const child = await User.findOne({ + _id: childId + }, { + fields: { + _id: true + } + }); + + if (child === null) { + return rej('user not found'); + } + + // 以前のセッションはすべて削除しておく + await Matching.remove({ + parentId: user._id + }); + + // セッションを作成 + const matching = await Matching.insert({ + createdAt: new Date(), + parentId: user._id, + childId: child._id + }); + + // Reponse + res(); + + const packed = await packMatching(matching, child); + + // 招待 + publishOthelloStream(child._id, 'invited', packed); + + publishUserStream(child._id, 'othello_invited', packed); + } +}); diff --git a/src/server/api/endpoints/othello/match/cancel.ts b/src/server/api/endpoints/othello/match/cancel.ts new file mode 100644 index 0000000000..562e691061 --- /dev/null +++ b/src/server/api/endpoints/othello/match/cancel.ts @@ -0,0 +1,9 @@ +import Matching from '../../../../../models/othello-matching'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + await Matching.remove({ + parentId: user._id + }); + + res(); +}); diff --git a/src/server/api/endpoints/stats.ts b/src/server/api/endpoints/stats.ts new file mode 100644 index 0000000000..52e5195484 --- /dev/null +++ b/src/server/api/endpoints/stats.ts @@ -0,0 +1,48 @@ +/** + * Module dependencies + */ +import Note from '../../../models/note'; +import User from '../../../models/user'; + +/** + * @swagger + * /stats: + * note: + * summary: Show the misskey's statistics + * responses: + * 200: + * description: Success + * schema: + * type: object + * properties: + * notesCount: + * description: count of all notes of misskey + * type: number + * usersCount: + * description: count of all users of misskey + * type: number + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Show the misskey's statistics + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = params => new Promise(async (res, rej) => { + const notesCount = await Note + .count(); + + const usersCount = await User + .count(); + + res({ + notesCount: notesCount, + usersCount: usersCount + }); +}); diff --git a/src/server/api/endpoints/sw/register.ts b/src/server/api/endpoints/sw/register.ts new file mode 100644 index 0000000000..3fe0bda4ee --- /dev/null +++ b/src/server/api/endpoints/sw/register.ts @@ -0,0 +1,44 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Subscription from '../../../../models/sw-subscription'; + +/** + * subscribe service worker + */ +module.exports = async (params, user, app) => new Promise(async (res, rej) => { + // Get 'endpoint' parameter + const [endpoint, endpointErr] = $(params.endpoint).string().$; + if (endpointErr) return rej('invalid endpoint param'); + + // Get 'auth' parameter + const [auth, authErr] = $(params.auth).string().$; + if (authErr) return rej('invalid auth param'); + + // Get 'publickey' parameter + const [publickey, publickeyErr] = $(params.publickey).string().$; + if (publickeyErr) return rej('invalid publickey param'); + + // if already subscribed + const exist = await Subscription.findOne({ + userId: user._id, + endpoint: endpoint, + auth: auth, + publickey: publickey, + deletedAt: { $exists: false } + }); + + if (exist !== null) { + return res(); + } + + await Subscription.insert({ + userId: user._id, + endpoint: endpoint, + auth: auth, + publickey: publickey + }); + + res(); +}); diff --git a/src/server/api/endpoints/username/available.ts b/src/server/api/endpoints/username/available.ts new file mode 100644 index 0000000000..bd27c37de0 --- /dev/null +++ b/src/server/api/endpoints/username/available.ts @@ -0,0 +1,32 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import { validateUsername } from '../../../../models/user'; + +/** + * Check available username + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = async (params) => new Promise(async (res, rej) => { + // Get 'username' parameter + const [username, usernameError] = $(params.username).string().pipe(validateUsername).$; + if (usernameError) return rej('invalid username param'); + + // Get exist + const exist = await User + .count({ + host: null, + usernameLower: username.toLowerCase() + }, { + limit: 1 + }); + + // Reply + res({ + available: exist === 0 + }); +}); diff --git a/src/server/api/endpoints/users.ts b/src/server/api/endpoints/users.ts new file mode 100644 index 0000000000..e82d72748c --- /dev/null +++ b/src/server/api/endpoints/users.ts @@ -0,0 +1,56 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { pack } from '../../../models/user'; + +/** + * Lists all users + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort, sortError] = $(params.sort).optional.string().or('+follower|-follower').$; + if (sortError) return rej('invalid sort param'); + + // Construct query + let _sort; + if (sort) { + if (sort == '+follower') { + _sort = { + followersCount: -1 + }; + } else if (sort == '-follower') { + _sort = { + followersCount: 1 + }; + } + } else { + _sort = { + _id: -1 + }; + } + + // Issue query + const users = await User + .find({}, { + limit: limit, + sort: _sort, + skip: offset + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me)))); +}); diff --git a/src/server/api/endpoints/users/followers.ts b/src/server/api/endpoints/users/followers.ts new file mode 100644 index 0000000000..0222313e81 --- /dev/null +++ b/src/server/api/endpoints/users/followers.ts @@ -0,0 +1,91 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import Following from '../../../../models/following'; +import { pack } from '../../../../models/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get followers of a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).id().$; + if (userIdErr) return rej('invalid userId param'); + + // Get 'iknow' parameter + const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$; + if (iknowErr) return rej('invalid iknow param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + if (cursorErr) return rej('invalid cursor param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const query = { + followeeId: user._id + } as any; + + // ログインしていてかつ iknow フラグがあるとき + if (me && iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.followerId = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get followers + const following = await Following + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = following.length === limit + 1; + if (inStock) { + following.pop(); + } + + // Serialize + const users = await Promise.all(following.map(async f => + await pack(f.followerId, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? following[following.length - 1]._id : null, + }); +}); diff --git a/src/server/api/endpoints/users/following.ts b/src/server/api/endpoints/users/following.ts new file mode 100644 index 0000000000..2372f57fbe --- /dev/null +++ b/src/server/api/endpoints/users/following.ts @@ -0,0 +1,91 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../../models/user'; +import Following from '../../../../models/following'; +import { pack } from '../../../../models/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get following users of a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).id().$; + if (userIdErr) return rej('invalid userId param'); + + // Get 'iknow' parameter + const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$; + if (iknowErr) return rej('invalid iknow param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + if (cursorErr) return rej('invalid cursor param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const query = { + followerId: user._id + } as any; + + // ログインしていてかつ iknow フラグがあるとき + if (me && iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.followeeId = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get followers + const following = await Following + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = following.length === limit + 1; + if (inStock) { + following.pop(); + } + + // Serialize + const users = await Promise.all(following.map(async f => + await pack(f.followeeId, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? following[following.length - 1]._id : null, + }); +}); diff --git a/src/server/api/endpoints/users/get_frequently_replied_users.ts b/src/server/api/endpoints/users/get_frequently_replied_users.ts new file mode 100644 index 0000000000..7a98f44e98 --- /dev/null +++ b/src/server/api/endpoints/users/get_frequently_replied_users.ts @@ -0,0 +1,99 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Note from '../../../../models/note'; +import User, { pack } from '../../../../models/user'; + +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).id().$; + if (userIdErr) return rej('invalid userId param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + // Fetch recent notes + const recentNotes = await Note.find({ + userId: user._id, + replyId: { + $exists: true, + $ne: null + } + }, { + sort: { + _id: -1 + }, + limit: 1000, + fields: { + _id: false, + replyId: true + } + }); + + // 投稿が少なかったら中断 + if (recentNotes.length === 0) { + return res([]); + } + + const replyTargetNotes = await Note.find({ + _id: { + $in: recentNotes.map(p => p.replyId) + }, + userId: { + $ne: user._id + } + }, { + fields: { + _id: false, + userId: true + } + }); + + const repliedUsers = {}; + + // Extract replies from recent notes + replyTargetNotes.forEach(note => { + const userId = note.userId.toString(); + if (repliedUsers[userId]) { + repliedUsers[userId]++; + } else { + repliedUsers[userId] = 1; + } + }); + + // Calc peak + let peak = 0; + Object.keys(repliedUsers).forEach(user => { + if (repliedUsers[user] > peak) peak = repliedUsers[user]; + }); + + // Sort replies by frequency + const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); + + // Extract top replied users + const topRepliedUsers = repliedUsersSorted.slice(0, limit); + + // Make replies object (includes weights) + const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ + user: await pack(user, me, { detail: true }), + weight: repliedUsers[user] / peak + }))); + + // Response + res(repliesObj); +}); diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts new file mode 100644 index 0000000000..e91b75e1d3 --- /dev/null +++ b/src/server/api/endpoints/users/notes.ts @@ -0,0 +1,133 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import getHostLower from '../../common/get-host-lower'; +import Note, { pack } from '../../../../models/note'; +import User from '../../../../models/user'; + +/** + * Get notes of a user + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).optional.id().$; + if (userIdErr) return rej('invalid userId param'); + + // Get 'username' parameter + const [username, usernameErr] = $(params.username).optional.string().$; + if (usernameErr) return rej('invalid username param'); + + if (userId === undefined && username === undefined) { + return rej('userId or pair of username and host is required'); + } + + // Get 'host' parameter + const [host, hostErr] = $(params.host).optional.string().$; + if (hostErr) return rej('invalid host param'); + + if (userId === undefined && host === undefined) { + return rej('userId or pair of username and host is required'); + } + + // Get 'includeReplies' parameter + const [includeReplies = true, includeRepliesErr] = $(params.includeReplies).optional.boolean().$; + if (includeRepliesErr) return rej('invalid includeReplies param'); + + // Get 'withMedia' parameter + const [withMedia = false, withMediaErr] = $(params.withMedia).optional.boolean().$; + if (withMediaErr) return rej('invalid withMedia param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + if (sinceIdErr) return rej('invalid sinceId param'); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + if (untilIdErr) return rej('invalid untilId param'); + + // Get 'sinceDate' parameter + const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$; + if (sinceDateErr) throw 'invalid sinceDate param'; + + // Get 'untilDate' parameter + const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$; + if (untilDateErr) throw 'invalid untilDate param'; + + // Check if only one of sinceId, untilId, sinceDate, untilDate specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; + } + + const q = userId !== undefined + ? { _id: userId } + : { usernameLower: username.toLowerCase(), hostLower: getHostLower(host) } ; + + // Lookup user + const user = await User.findOne(q, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + userId: user._id + } as any; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } else if (sinceDate) { + sort._id = 1; + query.createdAt = { + $gt: new Date(sinceDate) + }; + } else if (untilDate) { + query.createdAt = { + $lt: new Date(untilDate) + }; + } + + if (!includeReplies) { + query.replyId = null; + } + + if (withMedia) { + query.mediaIds = { + $exists: true, + $ne: [] + }; + } + //#endregion + + // Issue query + const notes = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(notes.map(async (note) => + await pack(note, me) + ))); +}); diff --git a/src/server/api/endpoints/users/recommendation.ts b/src/server/api/endpoints/users/recommendation.ts new file mode 100644 index 0000000000..2de22da13e --- /dev/null +++ b/src/server/api/endpoints/users/recommendation.ts @@ -0,0 +1,53 @@ +/** + * Module dependencies + */ +const ms = require('ms'); +import $ from 'cafy'; +import User, { pack } from '../../../../models/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get recommended users + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // 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 + }, + $or: [ + { + 'lastUsedAt': { + $gte: new Date(Date.now() - ms('7days')) + } + }, { + host: { $ne: null } + } + ] + }, { + limit: limit, + skip: offset, + sort: { + followersCount: -1 + } + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); +}); diff --git a/src/server/api/endpoints/users/search.ts b/src/server/api/endpoints/users/search.ts new file mode 100644 index 0000000000..da30f47c2a --- /dev/null +++ b/src/server/api/endpoints/users/search.ts @@ -0,0 +1,98 @@ +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import $ from 'cafy'; +import User, { pack } from '../../../../models/user'; +import config from '../../../../config'; +const escapeRegexp = require('escape-regexp'); + +/** + * Search a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'query' parameter + const [query, queryError] = $(params.query).string().pipe(x => x != '').$; + if (queryError) return rej('invalid query param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'max' parameter + const [max = 10, maxErr] = $(params.max).optional.number().range(1, 30).$; + if (maxErr) return rej('invalid max param'); + + // If Elasticsearch is available, search by $ + // If not, search by MongoDB + (config.elasticsearch.enable ? byElasticsearch : byNative) + (res, rej, me, query, offset, max); +}); + +// Search by MongoDB +async function byNative(res, rej, me, query, offset, max) { + const escapedQuery = escapeRegexp(query); + + // Search users + const users = await User + .find({ + $or: [{ + usernameLower: new RegExp(escapedQuery.replace('@', '').toLowerCase()) + }, { + name: new RegExp(escapedQuery) + }] + }, { + limit: max + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); +} + +// Search by Elasticsearch +async function byElasticsearch(res, rej, me, query, offset, max) { + const es = require('../../db/elasticsearch'); + + es.search({ + index: 'misskey', + type: 'user', + body: { + size: max, + from: offset, + query: { + simple_query_string: { + fields: ['username', 'name', 'bio'], + query: query, + default_operator: 'and' + } + } + } + }, async (error, response) => { + if (error) { + console.error(error); + return res(500); + } + + if (response.hits.total === 0) { + return res([]); + } + + const hits = response.hits.hits.map(hit => new mongo.ObjectID(hit._id)); + + const users = await User + .find({ + _id: { + $in: hits + } + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); + }); +} diff --git a/src/server/api/endpoints/users/search_by_username.ts b/src/server/api/endpoints/users/search_by_username.ts new file mode 100644 index 0000000000..5f6ececff9 --- /dev/null +++ b/src/server/api/endpoints/users/search_by_username.ts @@ -0,0 +1,38 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { pack } from '../../../../models/user'; + +/** + * Search a user by username + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'query' parameter + const [query, queryError] = $(params.query).string().$; + if (queryError) return rej('invalid query param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + const users = await User + .find({ + usernameLower: new RegExp(query.toLowerCase()) + }, { + limit: limit, + skip: offset + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); +}); diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts new file mode 100644 index 0000000000..7e7f5dc488 --- /dev/null +++ b/src/server/api/endpoints/users/show.ts @@ -0,0 +1,56 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { pack } from '../../../../models/user'; +import resolveRemoteUser from '../../../../remote/resolve-user'; + +const cursorOption = { fields: { data: false } }; + +/** + * Show a user + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + let user; + + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).optional.id().$; + if (userIdErr) return rej('invalid userId param'); + + // Get 'username' parameter + const [username, usernameErr] = $(params.username).optional.string().$; + if (usernameErr) return rej('invalid username param'); + + // Get 'host' parameter + const [host, hostErr] = $(params.host).nullable.optional.string().$; + if (hostErr) return rej('invalid host param'); + + if (userId === undefined && typeof username !== 'string') { + return rej('userId or pair of username and host is required'); + } + + // Lookup user + if (typeof host === 'string') { + try { + user = await resolveRemoteUser(username, host, cursorOption); + } catch (e) { + console.warn(`failed to resolve remote user: ${e}`); + return rej('failed to resolve remote user'); + } + } else { + const q = userId !== undefined + ? { _id: userId } + : { usernameLower: username.toLowerCase(), host: null }; + + user = await User.findOne(q, cursorOption); + + if (user === null) { + return rej('user not found'); + } + } + + // Send response + res(await pack(user, me, { + detail: true + })); +}); diff --git a/src/server/api/index.ts b/src/server/api/index.ts new file mode 100644 index 0000000000..5fbacd8a0e --- /dev/null +++ b/src/server/api/index.ts @@ -0,0 +1,54 @@ +/** + * API Server + */ + +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as cors from 'cors'; +import * as multer from 'multer'; + +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(bodyParser.json({ + type: ['application/json', 'text/plain'], + verify: (req, res, buf, encoding) => { + if (buf && buf.length) { + (req as any).rawBody = buf.toString(encoding || 'utf8'); + } + } +})); +app.use(cors()); + +app.get('/', (req, res) => { + res.send('YEE HAW'); +}); + +/** + * Register endpoint handlers + */ +endpoints.forEach(endpoint => + endpoint.withFile ? + app.post(`/${endpoint.name}`, + endpoint.withFile ? multer({ storage: multer.diskStorage({}) }).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); + +require('./service/github')(app); +require('./service/twitter')(app); + +require('./bot/interfaces/line')(app); + +module.exports = app; diff --git a/src/server/api/limitter.ts b/src/server/api/limitter.ts new file mode 100644 index 0000000000..b84e16ecde --- /dev/null +++ b/src/server/api/limitter.ts @@ -0,0 +1,83 @@ +import * as Limiter from 'ratelimiter'; +import * as debug from 'debug'; +import limiterDB from '../../db/redis'; +import { Endpoint } from './endpoints'; +import getAcct from '../../acct/render'; +import { IUser } from '../../models/user'; + +const log = debug('misskey:limitter'); + +export default (endpoint: Endpoint, user: IUser) => new Promise((ok, reject) => { + const limitation = endpoint.limit; + + const key = limitation.hasOwnProperty('key') + ? limitation.key + : endpoint.name; + + const hasShortTermLimit = + limitation.hasOwnProperty('minInterval'); + + const hasLongTermLimit = + limitation.hasOwnProperty('duration') && + limitation.hasOwnProperty('max'); + + if (hasShortTermLimit) { + min(); + } else if (hasLongTermLimit) { + max(); + } else { + ok(); + } + + // Short-term limit + function min() { + const minIntervalLimiter = new Limiter({ + id: `${user._id}:${key}:min`, + duration: limitation.minInterval, + max: 1, + db: limiterDB + }); + + minIntervalLimiter.get((err, info) => { + if (err) { + return reject('ERR'); + } + + log(`@${getAcct(user)} ${endpoint.name} min remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject('BRIEF_REQUEST_INTERVAL'); + } else { + if (hasLongTermLimit) { + max(); + } else { + ok(); + } + } + }); + } + + // Long term limit + function max() { + const limiter = new Limiter({ + id: `${user._id}:${key}`, + duration: limitation.duration, + max: limitation.max, + db: limiterDB + }); + + limiter.get((err, info) => { + if (err) { + return reject('ERR'); + } + + log(`@${getAcct(user)} ${endpoint.name} max remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject('RATE_LIMIT_EXCEEDED'); + } else { + ok(); + } + }); + } +}); diff --git a/src/server/api/private/signin.ts b/src/server/api/private/signin.ts new file mode 100644 index 0000000000..665ee21ebd --- /dev/null +++ b/src/server/api/private/signin.ts @@ -0,0 +1,89 @@ +import * as express from 'express'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import User, { ILocalUser } from '../../../models/user'; +import Signin, { pack } from '../../../models/signin'; +import event from '../../../publishers/stream'; +import signin from '../common/signin'; +import config from '../../../config'; + +export default async (req: express.Request, res: express.Response) => { + res.header('Access-Control-Allow-Origin', config.url); + res.header('Access-Control-Allow-Credentials', 'true'); + + const username = req.body['username']; + const password = req.body['password']; + const token = req.body['token']; + + if (typeof username != 'string') { + res.sendStatus(400); + return; + } + + if (typeof password != 'string') { + res.sendStatus(400); + return; + } + + if (token != null && typeof token != 'string') { + res.sendStatus(400); + return; + } + + // Fetch user + const user = await User.findOne({ + usernameLower: username.toLowerCase(), + host: null + }, { + fields: { + data: false, + 'profile': false + } + }) as ILocalUser; + + if (user === null) { + res.status(404).send({ + error: 'user not found' + }); + return; + } + + // Compare password + const same = await bcrypt.compare(password, user.password); + + if (same) { + if (user.twoFactorEnabled) { + const verified = (speakeasy as any).totp.verify({ + secret: user.twoFactorSecret, + encoding: 'base32', + token: token + }); + + if (verified) { + signin(res, user, false); + } else { + res.status(400).send({ + error: 'invalid token' + }); + } + } else { + signin(res, user, false); + } + } else { + res.status(400).send({ + error: 'incorrect password' + }); + } + + // Append signin history + const record = await Signin.insert({ + createdAt: new Date(), + userId: user._id, + ip: req.ip, + headers: req.headers, + success: same + }); + + // Publish signin event + event(user._id, 'signin', await pack(record)); +}; diff --git a/src/server/api/private/signup.ts b/src/server/api/private/signup.ts new file mode 100644 index 0000000000..f441e1b754 --- /dev/null +++ b/src/server/api/private/signup.ts @@ -0,0 +1,147 @@ +import * as uuid from 'uuid'; +import * as express from 'express'; +import * as bcrypt from 'bcryptjs'; +import { generate as generateKeypair } from '../../../crypto_key'; +import recaptcha = require('recaptcha-promise'); +import User, { IUser, validateUsername, validatePassword, pack } from '../../../models/user'; +import generateUserToken from '../common/generate-native-user-token'; +import config from '../../../config'; + +recaptcha.init({ + secret_key: config.recaptcha.secret_key +}); + +const home = { + left: [ + 'profile', + 'calendar', + 'activity', + 'rss', + 'trends', + 'photo-stream', + 'version' + ], + right: [ + 'broadcast', + 'notifications', + 'users', + 'polls', + 'server', + 'donation', + 'nav', + 'tips' + ] +}; + +export default async (req: express.Request, res: express.Response) => { + // Verify recaptcha + // ただしテスト時はこの機構は障害となるため無効にする + if (process.env.NODE_ENV !== 'test') { + 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']; + + // Validate username + if (!validateUsername(username)) { + res.sendStatus(400); + return; + } + + // Validate password + if (!validatePassword(password)) { + res.sendStatus(400); + return; + } + + // Fetch exist user that same username + const usernameExist = await User + .count({ + usernameLower: username.toLowerCase(), + host: null + }, { + limit: 1 + }); + + // Check username already used + if (usernameExist !== 0) { + res.sendStatus(400); + return; + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(password, salt); + + // Generate secret + const secret = generateUserToken(); + + //#region Construct home data + const homeData = []; + + home.left.forEach(widget => { + homeData.push({ + name: widget, + id: uuid(), + place: 'left', + data: {} + }); + }); + + home.right.forEach(widget => { + homeData.push({ + name: widget, + id: uuid(), + place: 'right', + data: {} + }); + }); + //#endregion + + // Create account + const account: IUser = await User.insert({ + avatarId: null, + bannerId: null, + createdAt: new Date(), + description: null, + followersCount: 0, + followingCount: 0, + name: null, + notesCount: 0, + driveCapacity: 1024 * 1024 * 128, // 128MiB + username: username, + usernameLower: username.toLowerCase(), + host: null, + hostLower: null, + keypair: generateKeypair(), + token: secret, + email: null, + links: null, + password: hash, + profile: { + bio: null, + birthday: null, + blood: null, + gender: null, + handedness: null, + height: null, + location: null, + weight: null + }, + settings: { + autoWatch: true + }, + clientSettings: { + home: homeData + } + }); + + // Response + res.send(await pack(account)); +}; diff --git a/src/server/api/service/github.ts b/src/server/api/service/github.ts new file mode 100644 index 0000000000..bc8d3c6a7d --- /dev/null +++ b/src/server/api/service/github.ts @@ -0,0 +1,140 @@ +import * as EventEmitter from 'events'; +import * as express from 'express'; +import * as request from 'request'; +const crypto = require('crypto'); + +import User from '../../../models/user'; +import createNote from '../../../services/note/create'; +import config from '../../../config'; + +module.exports = async (app: express.Application) => { + if (config.github_bot == null) return; + + const bot = await User.findOne({ + usernameLower: config.github_bot.username.toLowerCase() + }); + + if (bot == null) { + console.warn(`GitHub hook bot specified, but not found: @${config.github_bot.username}`); + return; + } + + const post = text => createNote(bot, { text }); + + const handler = new EventEmitter(); + + app.post('/hooks/github', (req, res, next) => { + // req.headers['x-hub-signature'] および + // req.headers['x-github-event'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + if ((new Buffer(req.headers['x-hub-signature'] as string)).equals(new Buffer(`sha1=${crypto.createHmac('sha1', config.github_bot.hook_secret).update(JSON.stringify(req.body)).digest('hex')}`))) { + handler.emit(req.headers['x-github-event'] as string, req.body); + res.sendStatus(200); + } else { + res.sendStatus(400); + } + }); + + handler.on('status', event => { + const state = event.state; + switch (state) { + case 'error': + case 'failure': + const commit = event.commit; + const parent = commit.parents[0]; + + // Fetch parent status + request({ + url: `${parent.url}/statuses`, + headers: { + 'User-Agent': 'misskey' + } + }, (err, res, body) => { + if (err) { + console.error(err); + return; + } + const parentStatuses = JSON.parse(body); + const parentState = parentStatuses[0].state; + const stillFailed = parentState == 'failure' || parentState == 'error'; + if (stillFailed) { + post(`**⚠️BUILD STILL FAILED⚠️**: ?[${commit.commit.message}](${commit.html_url})`); + } else { + post(`**🚨BUILD FAILED🚨**: →→→?[${commit.commit.message}](${commit.html_url})←←←`); + } + }); + break; + } + }); + + handler.on('push', event => { + const ref = event.ref; + switch (ref) { + case 'refs/heads/master': + const pusher = event.pusher; + const compare = event.compare; + const commits = event.commits; + post([ + `Pushed by **${pusher.name}** with ?[${commits.length} commit${commits.length > 1 ? 's' : ''}](${compare}):`, + commits.reverse().map(commit => `・[?[${commit.id.substr(0, 7)}](${commit.url})] ${commit.message.split('\n')[0]}`).join('\n'), + ].join('\n')); + break; + case 'refs/heads/release': + const commit = event.commits[0]; + post(`RELEASED: ${commit.message}`); + break; + } + }); + + handler.on('issues', event => { + const issue = event.issue; + const action = event.action; + let title: string; + switch (action) { + case 'opened': title = 'Issue opened'; break; + case 'closed': title = 'Issue closed'; break; + case 'reopened': title = 'Issue reopened'; break; + default: return; + } + post(`${title}: <${issue.number}>「${issue.title}」\n${issue.html_url}`); + }); + + handler.on('issue_comment', event => { + const issue = event.issue; + const comment = event.comment; + const action = event.action; + let text: string; + switch (action) { + case 'created': text = `Commented to「${issue.title}」:${comment.user.login}「${comment.body}」\n${comment.html_url}`; break; + default: return; + } + post(text); + }); + + handler.on('watch', event => { + const sender = event.sender; + post(`⭐️ Starred by **${sender.login}** ⭐️`); + }); + + handler.on('fork', event => { + const repo = event.forkee; + post(`🍴 Forked:\n${repo.html_url} 🍴`); + }); + + handler.on('pull_request', event => { + const pr = event.pull_request; + const action = event.action; + let text: string; + switch (action) { + case 'opened': text = `New Pull Request:「${pr.title}」\n${pr.html_url}`; break; + case 'reopened': text = `Pull Request Reopened:「${pr.title}」\n${pr.html_url}`; break; + case 'closed': + text = pr.merged + ? `Pull Request Merged!:「${pr.title}」\n${pr.html_url}` + : `Pull Request Closed:「${pr.title}」\n${pr.html_url}`; + break; + default: return; + } + post(text); + }); +}; diff --git a/src/server/api/service/twitter.ts b/src/server/api/service/twitter.ts new file mode 100644 index 0000000000..e5239fa171 --- /dev/null +++ b/src/server/api/service/twitter.ts @@ -0,0 +1,176 @@ +import * as express from 'express'; +import * as cookie from 'cookie'; +import * as uuid from 'uuid'; +// import * as Twitter from 'twitter'; +// const Twitter = require('twitter'); +import autwh from 'autwh'; +import redis from '../../../db/redis'; +import User, { pack } from '../../../models/user'; +import event from '../../../publishers/stream'; +import config from '../../../config'; +import signin from '../common/signin'; + +module.exports = (app: express.Application) => { + function getUserToken(req: express.Request) { + // req.headers['cookie'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + return ((req.headers['cookie'] as string || '').match(/i=(!\w+)/) || [null, null])[1]; + } + + function compareOrigin(req: express.Request) { + function normalizeUrl(url: string) { + return url[url.length - 1] === '/' ? url.substr(0, url.length - 1) : url; + } + + // req.headers['referer'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + const referer = req.headers['referer'] as string; + + return (normalizeUrl(referer) == normalizeUrl(config.url)); + } + + app.get('/disconnect/twitter', async (req, res): Promise<any> => { + if (!compareOrigin(req)) { + res.status(400).send('invalid origin'); + return; + } + + const userToken = getUserToken(req); + if (userToken == null) return res.send('plz signin'); + + const user = await User.findOneAndUpdate({ + host: null, + 'token': userToken + }, { + $set: { + 'twitter': null + } + }); + + res.send(`Twitterの連携を解除しました :v:`); + + // Publish i updated event + event(user._id, 'i_updated', await pack(user, user, { + detail: true, + includeSecrets: true + })); + }); + + if (config.twitter == null) { + app.get('/connect/twitter', (req, res) => { + res.send('現在Twitterへ接続できません (このインスタンスではTwitterはサポートされていません)'); + }); + + app.get('/signin/twitter', (req, res) => { + res.send('現在Twitterへ接続できません (このインスタンスではTwitterはサポートされていません)'); + }); + + return; + } + + const twAuth = autwh({ + consumerKey: config.twitter.consumer_key, + consumerSecret: config.twitter.consumer_secret, + callbackUrl: `${config.url}/api/tw/cb` + }); + + app.get('/connect/twitter', async (req, res): Promise<any> => { + if (!compareOrigin(req)) { + res.status(400).send('invalid origin'); + return; + } + + const userToken = getUserToken(req); + if (userToken == null) return res.send('plz signin'); + + const ctx = await twAuth.begin(); + redis.set(userToken, JSON.stringify(ctx)); + res.redirect(ctx.url); + }); + + app.get('/signin/twitter', async (req, res): Promise<any> => { + const ctx = await twAuth.begin(); + + const sessid = uuid(); + + redis.set(sessid, JSON.stringify(ctx)); + + const expires = 1000 * 60 * 60; // 1h + res.cookie('signin_with_twitter_session_id', sessid, { + path: '/', + domain: `.${config.host}`, + secure: config.url.substr(0, 5) === 'https', + httpOnly: true, + expires: new Date(Date.now() + expires), + maxAge: expires + }); + + res.redirect(ctx.url); + }); + + app.get('/tw/cb', (req, res): any => { + const userToken = getUserToken(req); + + if (userToken == null) { + // req.headers['cookie'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + const cookies = cookie.parse((req.headers['cookie'] as string || '')); + + const sessid = cookies['signin_with_twitter_session_id']; + + if (sessid == undefined) { + res.status(400).send('invalid session'); + return; + } + + redis.get(sessid, async (_, ctx) => { + const result = await twAuth.done(JSON.parse(ctx), req.query.oauth_verifier); + + const user = await User.findOne({ + host: null, + 'twitter.userId': result.userId + }); + + if (user == null) { + res.status(404).send(`@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + signin(res, user, true); + }); + } else { + const verifier = req.query.oauth_verifier; + + if (verifier == null) { + res.status(400).send('invalid session'); + return; + } + + redis.get(userToken, async (_, ctx) => { + const result = await twAuth.done(JSON.parse(ctx), verifier); + + const user = await User.findOneAndUpdate({ + host: null, + 'token': userToken + }, { + $set: { + 'twitter': { + accessToken: result.accessToken, + accessTokenSecret: result.accessTokenSecret, + userId: result.userId, + screenName: result.screenName + } + } + }); + + res.send(`Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`); + + // Publish i updated event + event(user._id, 'i_updated', await pack(user, user, { + detail: true, + includeSecrets: true + })); + }); + } + }); +}; diff --git a/src/server/api/stream/channel.ts b/src/server/api/stream/channel.ts new file mode 100644 index 0000000000..cb04278237 --- /dev/null +++ b/src/server/api/stream/channel.ts @@ -0,0 +1,14 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import { ParsedUrlQuery } from 'querystring'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient): void { + const q = request.resourceURL.query as ParsedUrlQuery; + const channel = q.channel; + + // Subscribe channel stream + subscriber.subscribe(`misskey:channel-stream:${channel}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/server/api/stream/drive.ts b/src/server/api/stream/drive.ts new file mode 100644 index 0000000000..c97ab80dcc --- /dev/null +++ b/src/server/api/stream/drive.ts @@ -0,0 +1,10 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + // Subscribe drive stream + subscriber.subscribe(`misskey:drive-stream:${user._id}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/server/api/stream/home.ts b/src/server/api/stream/home.ts new file mode 100644 index 0000000000..e9c0924f31 --- /dev/null +++ b/src/server/api/stream/home.ts @@ -0,0 +1,113 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import * as debug from 'debug'; + +import User, { IUser } from '../../../models/user'; +import Mute from '../../../models/mute'; +import { pack as packNote } from '../../../models/note'; +import readNotification from '../common/read-notification'; +import call from '../call'; +import { IApp } from '../../../models/app'; + +const log = debug('misskey'); + +export default async function( + request: websocket.request, + connection: websocket.connection, + subscriber: redis.RedisClient, + user: IUser, + app: IApp +) { + // Subscribe Home stream channel + subscriber.subscribe(`misskey:user-stream:${user._id}`); + + const mute = await Mute.find({ + muterId: user._id, + deletedAt: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.muteeId.toString()); + + subscriber.on('message', async (channel, data) => { + switch (channel.split(':')[1]) { + case 'user-stream': + try { + const x = JSON.parse(data); + + if (x.type == 'note') { + if (mutedUserIds.indexOf(x.body.userId) != -1) { + return; + } + if (x.body.reply != null && mutedUserIds.indexOf(x.body.reply.userId) != -1) { + return; + } + if (x.body.renote != null && mutedUserIds.indexOf(x.body.renote.userId) != -1) { + return; + } + } else if (x.type == 'notification') { + if (mutedUserIds.indexOf(x.body.userId) != -1) { + return; + } + } + + connection.send(data); + } catch (e) { + connection.send(data); + } + break; + case 'note-stream': + const noteId = channel.split(':')[2]; + log(`RECEIVED: ${noteId} ${data} by @${user.username}`); + const note = await packNote(noteId, user, { + detail: true + }); + connection.send(JSON.stringify({ + type: 'note-updated', + body: { + note: note + } + })); + break; + } + }); + + connection.on('message', data => { + const msg = JSON.parse(data.utf8Data); + + switch (msg.type) { + case 'api': + call(msg.endpoint, user, app, msg.data).then(res => { + connection.send(JSON.stringify({ + type: `api-res:${msg.id}`, + body: { res } + })); + }).catch(e => { + connection.send(JSON.stringify({ + type: `api-res:${msg.id}`, + body: { e } + })); + }); + break; + + case 'alive': + // Update lastUsedAt + User.update({ _id: user._id }, { + $set: { + 'lastUsedAt': new Date() + } + }); + break; + + case 'read_notification': + if (!msg.id) return; + readNotification(user._id, msg.id); + break; + + case 'capture': + if (!msg.id) return; + const noteId = msg.id; + log(`CAPTURE: ${noteId} by @${user.username}`); + subscriber.subscribe(`misskey:note-stream:${noteId}`); + break; + } + }); +} diff --git a/src/server/api/stream/messaging-index.ts b/src/server/api/stream/messaging-index.ts new file mode 100644 index 0000000000..c1b2fbc806 --- /dev/null +++ b/src/server/api/stream/messaging-index.ts @@ -0,0 +1,10 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + // Subscribe messaging index stream + subscriber.subscribe(`misskey:messaging-index-stream:${user._id}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/server/api/stream/messaging.ts b/src/server/api/stream/messaging.ts new file mode 100644 index 0000000000..3e6c2cd509 --- /dev/null +++ b/src/server/api/stream/messaging.ts @@ -0,0 +1,26 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import read from '../common/read-messaging-message'; +import { ParsedUrlQuery } from 'querystring'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + const q = request.resourceURL.query as ParsedUrlQuery; + const otherparty = q.otherparty as string; + + // 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; + read(user._id, otherparty, msg.id); + break; + } + }); +} diff --git a/src/server/api/stream/othello-game.ts b/src/server/api/stream/othello-game.ts new file mode 100644 index 0000000000..841e542610 --- /dev/null +++ b/src/server/api/stream/othello-game.ts @@ -0,0 +1,333 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import * as CRC32 from 'crc-32'; +import OthelloGame, { pack } from '../../../models/othello-game'; +import { publishOthelloGameStream } from '../../../publishers/stream'; +import Othello from '../../../othello/core'; +import * as maps from '../../../othello/maps'; +import { ParsedUrlQuery } from 'querystring'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user?: any): void { + const q = request.resourceURL.query as ParsedUrlQuery; + const gameId = q.game; + + // Subscribe game stream + subscriber.subscribe(`misskey:othello-game-stream:${gameId}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); + + connection.on('message', async (data) => { + const msg = JSON.parse(data.utf8Data); + + switch (msg.type) { + case 'accept': + accept(true); + break; + + case 'cancel-accept': + accept(false); + break; + + case 'update-settings': + if (msg.settings == null) return; + updateSettings(msg.settings); + break; + + case 'init-form': + if (msg.body == null) return; + initForm(msg.body); + break; + + case 'update-form': + if (msg.id == null || msg.value === undefined) return; + updateForm(msg.id, msg.value); + break; + + case 'message': + if (msg.body == null) return; + message(msg.body); + break; + + case 'set': + if (msg.pos == null) return; + set(msg.pos); + break; + + case 'check': + if (msg.crc32 == null) return; + check(msg.crc32); + break; + } + }); + + async function updateSettings(settings) { + const game = await OthelloGame.findOne({ _id: gameId }); + + if (game.isStarted) return; + if (!game.user1Id.equals(user._id) && !game.user2Id.equals(user._id)) return; + if (game.user1Id.equals(user._id) && game.user1Accepted) return; + if (game.user2Id.equals(user._id) && game.user2Accepted) return; + + await OthelloGame.update({ _id: gameId }, { + $set: { + settings + } + }); + + publishOthelloGameStream(gameId, 'update-settings', settings); + } + + async function initForm(form) { + const game = await OthelloGame.findOne({ _id: gameId }); + + if (game.isStarted) return; + if (!game.user1Id.equals(user._id) && !game.user2Id.equals(user._id)) return; + + const set = game.user1Id.equals(user._id) ? { + form1: form + } : { + form2: form + }; + + await OthelloGame.update({ _id: gameId }, { + $set: set + }); + + publishOthelloGameStream(gameId, 'init-form', { + userId: user._id, + form + }); + } + + async function updateForm(id, value) { + const game = await OthelloGame.findOne({ _id: gameId }); + + if (game.isStarted) return; + if (!game.user1Id.equals(user._id) && !game.user2Id.equals(user._id)) return; + + const form = game.user1Id.equals(user._id) ? game.form2 : game.form1; + + const item = form.find(i => i.id == id); + + if (item == null) return; + + item.value = value; + + const set = game.user1Id.equals(user._id) ? { + form2: form + } : { + form1: form + }; + + await OthelloGame.update({ _id: gameId }, { + $set: set + }); + + publishOthelloGameStream(gameId, 'update-form', { + userId: user._id, + id, + value + }); + } + + async function message(message) { + message.id = Math.random(); + publishOthelloGameStream(gameId, 'message', { + userId: user._id, + message + }); + } + + async function accept(accept: boolean) { + const game = await OthelloGame.findOne({ _id: gameId }); + + if (game.isStarted) return; + + let bothAccepted = false; + + if (game.user1Id.equals(user._id)) { + await OthelloGame.update({ _id: gameId }, { + $set: { + user1Accepted: accept + } + }); + + publishOthelloGameStream(gameId, 'change-accepts', { + user1: accept, + user2: game.user2Accepted + }); + + if (accept && game.user2Accepted) bothAccepted = true; + } else if (game.user2Id.equals(user._id)) { + await OthelloGame.update({ _id: gameId }, { + $set: { + user2Accepted: accept + } + }); + + publishOthelloGameStream(gameId, 'change-accepts', { + user1: game.user1Accepted, + user2: accept + }); + + if (accept && game.user1Accepted) bothAccepted = true; + } else { + return; + } + + if (bothAccepted) { + // 3秒後、まだacceptされていたらゲーム開始 + setTimeout(async () => { + const freshGame = await OthelloGame.findOne({ _id: gameId }); + if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return; + if (!freshGame.user1Accepted || !freshGame.user2Accepted) return; + + let bw: number; + if (freshGame.settings.bw == 'random') { + bw = Math.random() > 0.5 ? 1 : 2; + } else { + bw = freshGame.settings.bw as number; + } + + function getRandomMap() { + const mapCount = Object.entries(maps).length; + const rnd = Math.floor(Math.random() * mapCount); + return Object.entries(maps).find((x, i) => i == rnd)[1].data; + } + + const map = freshGame.settings.map != null ? freshGame.settings.map : getRandomMap(); + + await OthelloGame.update({ _id: gameId }, { + $set: { + startedAt: new Date(), + isStarted: true, + black: bw, + 'settings.map': map + } + }); + + //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理 + const o = new Othello(map, { + isLlotheo: freshGame.settings.isLlotheo, + canPutEverywhere: freshGame.settings.canPutEverywhere, + loopedBoard: freshGame.settings.loopedBoard + }); + + if (o.isEnded) { + let winner; + if (o.winner === true) { + winner = freshGame.black == 1 ? freshGame.user1Id : freshGame.user2Id; + } else if (o.winner === false) { + winner = freshGame.black == 1 ? freshGame.user2Id : freshGame.user1Id; + } else { + winner = null; + } + + await OthelloGame.update({ + _id: gameId + }, { + $set: { + isEnded: true, + winnerId: winner + } + }); + + publishOthelloGameStream(gameId, 'ended', { + winnerId: winner, + game: await pack(gameId, user) + }); + } + //#endregion + + publishOthelloGameStream(gameId, 'started', await pack(gameId, user)); + }, 3000); + } + } + + // 石を打つ + async function set(pos) { + const game = await OthelloGame.findOne({ _id: gameId }); + + if (!game.isStarted) return; + if (game.isEnded) return; + if (!game.user1Id.equals(user._id) && !game.user2Id.equals(user._id)) return; + + const o = new Othello(game.settings.map, { + isLlotheo: game.settings.isLlotheo, + canPutEverywhere: game.settings.canPutEverywhere, + loopedBoard: game.settings.loopedBoard + }); + + game.logs.forEach(log => { + o.put(log.color, log.pos); + }); + + const myColor = + (game.user1Id.equals(user._id) && game.black == 1) || (game.user2Id.equals(user._id) && game.black == 2) + ? true + : false; + + if (!o.canPut(myColor, pos)) return; + o.put(myColor, pos); + + let winner; + if (o.isEnded) { + if (o.winner === true) { + winner = game.black == 1 ? game.user1Id : game.user2Id; + } else if (o.winner === false) { + winner = game.black == 1 ? game.user2Id : game.user1Id; + } else { + winner = null; + } + } + + const log = { + at: new Date(), + color: myColor, + pos + }; + + const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString()); + + await OthelloGame.update({ + _id: gameId + }, { + $set: { + crc32, + isEnded: o.isEnded, + winnerId: winner + }, + $push: { + logs: log + } + }); + + publishOthelloGameStream(gameId, 'set', Object.assign(log, { + next: o.turn + })); + + if (o.isEnded) { + publishOthelloGameStream(gameId, 'ended', { + winnerId: winner, + game: await pack(gameId, user) + }); + } + } + + async function check(crc32) { + const game = await OthelloGame.findOne({ _id: gameId }); + + if (!game.isStarted) return; + + // 互換性のため + if (game.crc32 == null) return; + + if (crc32 !== game.crc32) { + connection.send(JSON.stringify({ + type: 'rescue', + body: await pack(game, user) + })); + } + } +} diff --git a/src/server/api/stream/othello.ts b/src/server/api/stream/othello.ts new file mode 100644 index 0000000000..fa62b05836 --- /dev/null +++ b/src/server/api/stream/othello.ts @@ -0,0 +1,29 @@ +import * as mongo from 'mongodb'; +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import Matching, { pack } from '../../../models/othello-matching'; +import publishUserStream from '../../../publishers/stream'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + // Subscribe othello stream + subscriber.subscribe(`misskey:othello-stream:${user._id}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); + + connection.on('message', async (data) => { + const msg = JSON.parse(data.utf8Data); + + switch (msg.type) { + case 'ping': + if (msg.id == null) return; + const matching = await Matching.findOne({ + parentId: user._id, + childId: new mongo.ObjectID(msg.id) + }); + if (matching == null) return; + publishUserStream(matching.childId, 'othello_invited', await pack(matching, matching.childId)); + break; + } + }); +} diff --git a/src/server/api/stream/requests.ts b/src/server/api/stream/requests.ts new file mode 100644 index 0000000000..d7bb5e6c5c --- /dev/null +++ b/src/server/api/stream/requests.ts @@ -0,0 +1,19 @@ +import * as websocket from 'websocket'; +import Xev from 'xev'; + +const ev = new Xev(); + +export default function(request: websocket.request, connection: websocket.connection): void { + const onRequest = request => { + connection.send(JSON.stringify({ + type: 'request', + body: request + })); + }; + + ev.addListener('request', onRequest); + + connection.on('close', () => { + ev.removeListener('request', onRequest); + }); +} diff --git a/src/server/api/stream/server.ts b/src/server/api/stream/server.ts new file mode 100644 index 0000000000..4ca2ad1b10 --- /dev/null +++ b/src/server/api/stream/server.ts @@ -0,0 +1,19 @@ +import * as websocket from 'websocket'; +import Xev from 'xev'; + +const ev = new Xev(); + +export default function(request: websocket.request, connection: websocket.connection): void { + const onStats = stats => { + connection.send(JSON.stringify({ + type: 'stats', + body: stats + })); + }; + + ev.addListener('stats', onStats); + + connection.on('close', () => { + ev.removeListener('stats', onStats); + }); +} diff --git a/src/server/api/streaming.ts b/src/server/api/streaming.ts new file mode 100644 index 0000000000..d586d7c08f --- /dev/null +++ b/src/server/api/streaming.ts @@ -0,0 +1,81 @@ +import * as http from 'http'; +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import config from '../../config'; + +import homeStream from './stream/home'; +import driveStream from './stream/drive'; +import messagingStream from './stream/messaging'; +import messagingIndexStream from './stream/messaging-index'; +import othelloGameStream from './stream/othello-game'; +import othelloStream from './stream/othello'; +import serverStream from './stream/server'; +import requestsStream from './stream/requests'; +import channelStream from './stream/channel'; +import { ParsedUrlQuery } from 'querystring'; +import authenticate from './authenticate'; + +module.exports = (server: http.Server) => { + /** + * Init websocket server + */ + const ws = new websocket.server({ + httpServer: server + }); + + ws.on('request', async (request) => { + const connection = request.accept(); + + if (request.resourceURL.pathname === '/server') { + serverStream(request, connection); + return; + } + + if (request.resourceURL.pathname === '/requests') { + requestsStream(request, connection); + return; + } + + // Connect to Redis + const subscriber = redis.createClient( + config.redis.port, config.redis.host); + + connection.on('close', () => { + subscriber.unsubscribe(); + subscriber.quit(); + }); + + if (request.resourceURL.pathname === '/channel') { + channelStream(request, connection, subscriber); + return; + } + + const q = request.resourceURL.query as ParsedUrlQuery; + const [user, app] = await authenticate(q.i as string); + + if (request.resourceURL.pathname === '/othello-game') { + othelloGameStream(request, connection, subscriber, user); + return; + } + + if (user == null) { + connection.send('authentication-failed'); + connection.close(); + return; + } + + const channel = + request.resourceURL.pathname === '/' ? homeStream : + request.resourceURL.pathname === '/drive' ? driveStream : + request.resourceURL.pathname === '/messaging' ? messagingStream : + request.resourceURL.pathname === '/messaging-index' ? messagingIndexStream : + request.resourceURL.pathname === '/othello' ? othelloStream : + null; + + if (channel !== null) { + channel(request, connection, subscriber, user, app); + } else { + connection.close(); + } + }); +}; diff --git a/src/server/file/assets/avatar.jpg b/src/server/file/assets/avatar.jpg Binary files differnew file mode 100644 index 0000000000..3c803f568e --- /dev/null +++ b/src/server/file/assets/avatar.jpg diff --git a/src/server/file/assets/bad-egg.png b/src/server/file/assets/bad-egg.png Binary files differnew file mode 100644 index 0000000000..a7c5930bd4 --- /dev/null +++ b/src/server/file/assets/bad-egg.png diff --git a/src/server/file/assets/dummy.png b/src/server/file/assets/dummy.png Binary files differnew file mode 100644 index 0000000000..39332b0c1b --- /dev/null +++ b/src/server/file/assets/dummy.png diff --git a/src/server/file/assets/not-an-image.png b/src/server/file/assets/not-an-image.png Binary files differnew file mode 100644 index 0000000000..bf98b293f7 --- /dev/null +++ b/src/server/file/assets/not-an-image.png diff --git a/src/server/file/assets/thumbnail-not-available.png b/src/server/file/assets/thumbnail-not-available.png Binary files differnew file mode 100644 index 0000000000..f960ce4d00 --- /dev/null +++ b/src/server/file/assets/thumbnail-not-available.png diff --git a/src/server/file/index.ts b/src/server/file/index.ts new file mode 100644 index 0000000000..8d21b0ba46 --- /dev/null +++ b/src/server/file/index.ts @@ -0,0 +1,167 @@ +/** + * File Server + */ + +import * as fs from 'fs'; +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as cors from 'cors'; +import * as mongodb from 'mongodb'; +import * as _gm from 'gm'; +import * as stream from 'stream'; + +import DriveFile, { getGridFSBucket } from '../../models/drive-file'; + +const gm = _gm.subClass({ + imageMagick: true +}); + +/** + * Init app + */ +const app = express(); + +app.disable('x-powered-by'); +app.locals.cache = true; +app.use(bodyParser.urlencoded({ extended: true })); +app.use(cors()); + +/** + * Statics + */ +app.use('/assets', express.static(`${__dirname}/assets`, { + maxAge: 1000 * 60 * 60 * 24 * 365 // 一年 +})); + +app.get('/', (req, res) => { + res.send('yee haw'); +}); + +app.get('/default-avatar.jpg', (req, res) => { + const file = fs.createReadStream(`${__dirname}/assets/avatar.jpg`); + send(file, 'image/jpeg', req, res); +}); + +app.get('/app-default.jpg', (req, res) => { + const file = fs.createReadStream(`${__dirname}/assets/dummy.png`); + send(file, 'image/png', req, res); +}); + +interface ISend { + contentType: string; + stream: stream.Readable; +} + +function thumbnail(data: stream.Readable, type: string, resize: number): ISend { + const readable: stream.Readable = (() => { + // 画像ではない場合 + if (!/^image\/.*$/.test(type)) { + // 使わないことにしたストリームはしっかり取り壊しておく + data.destroy(); + return fs.createReadStream(`${__dirname}/assets/not-an-image.png`); + } + + const imageType = type.split('/')[1]; + + // 画像でもPNGかJPEGでないならダメ + if (imageType != 'png' && imageType != 'jpeg') { + // 使わないことにしたストリームはしっかり取り壊しておく + data.destroy(); + return fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`); + } + + return data; + })(); + + let g = gm(readable); + + if (resize) { + g = g.resize(resize, resize); + } + + const stream = g + .compress('jpeg') + .quality(80) + .interlace('line') + .stream(); + + return { + contentType: 'image/jpeg', + stream + }; +} + +const commonReadableHandlerGenerator = (req: express.Request, res: express.Response) => (e: Error): void => { + console.dir(e); + req.destroy(); + res.destroy(e); +}; + +function send(readable: stream.Readable, type: string, req: express.Request, res: express.Response): void { + readable.on('error', commonReadableHandlerGenerator(req, res)); + + const data = ((): ISend => { + if (req.query.thumbnail !== undefined) { + return thumbnail(readable, type, req.query.size); + } + return { + contentType: type, + stream: readable + }; + })(); + + if (readable !== data.stream) { + data.stream.on('error', commonReadableHandlerGenerator(req, res)); + } + + if (req.query.download !== undefined) { + res.header('Content-Disposition', 'attachment'); + } + + res.header('Content-Type', data.contentType); + + data.stream.pipe(res); + + data.stream.on('end', () => { + res.end(); + }); +} + +async function sendFileById(req: express.Request, res: express.Response): Promise<void> { + // Validate id + if (!mongodb.ObjectID.isValid(req.params.id)) { + res.status(400).send('incorrect id'); + return; + } + + const fileId = new mongodb.ObjectID(req.params.id); + + // Fetch (drive) file + const file = await DriveFile.findOne({ _id: fileId }); + + // validate name + if (req.params.name !== undefined && req.params.name !== file.filename) { + res.status(404).send('there is no file has given name'); + return; + } + + if (file == null) { + res.status(404).sendFile(`${__dirname}/assets/dummy.png`); + return; + } + + const bucket = await getGridFSBucket(); + + const readable = bucket.openDownloadStream(fileId); + + send(readable, file.contentType, req, res); +} + +/** + * Routing + */ + +app.get('/:id', sendFileById); +app.get('/:id/:name', sendFileById); + +module.exports = app; diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000000..abb8992da5 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,94 @@ +/** + * Core Server + */ + +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import * as express from 'express'; +import * as morgan from 'morgan'; +import Accesses from 'accesses'; + +import activityPub from './activitypub'; +import webFinger from './webfinger'; +import log from './log-request'; +import config from '../config'; + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); +app.set('trust proxy', 'loopback'); + +// Log +if (config.accesses && config.accesses.enable) { + const accesses = new Accesses({ + appName: 'Misskey', + port: config.accesses.port + }); + + app.use(accesses.express); +} + +app.use(morgan(process.env.NODE_ENV == 'production' ? 'combined' : 'dev', { + // create a write stream (in append mode) + stream: config.accesslog ? fs.createWriteStream(config.accesslog) : null +})); + +app.use((req, res, next) => { + log(req); + next(); +}); + +// Drop request when without 'Host' header +app.use((req, res, next) => { + if (!req.headers['host']) { + res.sendStatus(400); + } else { + next(); + } +}); + +// 互換性のため +app.post('/meta', (req, res) => { + res.header('Access-Control-Allow-Origin', '*'); + res.json({ + version: 'nighthike' + }); +}); + +/** + * Register modules + */ +app.use('/api', require('./api')); +app.use('/files', require('./file')); +app.use(activityPub); +app.use(webFinger); +app.use(require('./web')); + +function createServer() { + if (config.https) { + const certs = {}; + Object.keys(config.https).forEach(k => { + certs[k] = fs.readFileSync(config.https[k]); + }); + return https.createServer(certs, app); + } else { + return http.createServer(app); + } +} + +export default () => new Promise(resolve => { + const server = createServer(); + + /** + * Steaming + */ + require('./api/streaming')(server); + + /** + * Server listen + */ + server.listen(config.port, resolve); +}); diff --git a/src/server/log-request.ts b/src/server/log-request.ts new file mode 100644 index 0000000000..e431aa271d --- /dev/null +++ b/src/server/log-request.ts @@ -0,0 +1,21 @@ +import * as crypto from 'crypto'; +import * as express from 'express'; +import * as proxyAddr from 'proxy-addr'; +import Xev from 'xev'; + +const ev = new Xev(); + +export default function(req: express.Request) { + const ip = proxyAddr(req, () => true); + + const md5 = crypto.createHash('md5'); + md5.update(ip); + const hashedIp = md5.digest('hex').substr(0, 3); + + ev.emit('request', { + ip: hashedIp, + method: req.method, + hostname: req.hostname, + path: req.originalUrl + }); +} diff --git a/src/server/web/docs.ts b/src/server/web/docs.ts new file mode 100644 index 0000000000..889532e17e --- /dev/null +++ b/src/server/web/docs.ts @@ -0,0 +1,24 @@ +/** + * Docs Server + */ + +import * as path from 'path'; +import * as express from 'express'; + +const docs = path.resolve(`${__dirname}/../../client/docs/`); + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); + +app.use('/assets', express.static(`${docs}/assets`)); + +/** + * Routing + */ +app.get(/^\/([a-z_\-\/]+?)$/, (req, res) => + res.sendFile(`${docs}/${req.params[0]}.html`)); + +module.exports = app; diff --git a/src/server/web/index.ts b/src/server/web/index.ts new file mode 100644 index 0000000000..5b1b6409b9 --- /dev/null +++ b/src/server/web/index.ts @@ -0,0 +1,64 @@ +/** + * Web Client Server + */ + +import * as path from 'path'; +import ms = require('ms'); + +// express modules +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as favicon from 'serve-favicon'; +import * as compression from 'compression'; + +const client = path.resolve(`${__dirname}/../../client/`); + +// Create server +const app = express(); +app.disable('x-powered-by'); + +app.use('/docs', require('./docs')); + +app.use(bodyParser.urlencoded({ extended: true })); +app.use(bodyParser.json({ + type: ['application/json', 'text/plain'] +})); +app.use(compression()); + +app.use((req, res, next) => { + res.header('X-Frame-Options', 'DENY'); + next(); +}); + +//#region static assets + +app.use(favicon(`${client}/assets/favicon.ico`)); +app.get('/apple-touch-icon.png', (req, res) => res.sendFile(`${client}/assets/apple-touch-icon.png`)); +app.use('/assets', express.static(`${client}/assets`, { + maxAge: ms('7 days') +})); +app.use('/assets/*.js', (req, res) => res.sendFile(`${client}/assets/404.js`)); +app.use('/assets', (req, res) => { + res.sendStatus(404); +}); + +// ServiceWroker +app.get(/^\/sw\.(.+?)\.js$/, (req, res) => + res.sendFile(`${client}/assets/sw.${req.params[0]}.js`)); + +// Manifest +app.get('/manifest.json', (req, res) => + res.sendFile(`${client}/assets/manifest.json`)); + +//#endregion + +app.get(/\/api:url/, require('./url-preview')); + +// Render base html for all requests +app.get('*', (req, res) => { + res.sendFile(path.resolve(`${client}/app/base.html`), { + maxAge: ms('7 days') + }); +}); + +module.exports = app; diff --git a/src/server/web/url-preview.ts b/src/server/web/url-preview.ts new file mode 100644 index 0000000000..0c5fd8a78e --- /dev/null +++ b/src/server/web/url-preview.ts @@ -0,0 +1,15 @@ +import * as express from 'express'; +import summaly from 'summaly'; + +module.exports = async (req: express.Request, res: express.Response) => { + const summary = await summaly(req.query.url); + summary.icon = wrap(summary.icon); + summary.thumbnail = wrap(summary.thumbnail); + res.send(summary); +}; + +function wrap(url: string): string { + return url != null + ? `https://images.weserv.nl/?url=${url.replace(/^https?:\/\//, '')}` + : null; +} diff --git a/src/server/webfinger.ts b/src/server/webfinger.ts new file mode 100644 index 0000000000..dbf0999f3e --- /dev/null +++ b/src/server/webfinger.ts @@ -0,0 +1,53 @@ +import * as express from 'express'; + +import config from '../config'; +import parseAcct from '../acct/parse'; +import User from '../models/user'; + +const app = express.Router(); + +app.get('/.well-known/webfinger', async (req, res) => { + if (typeof req.query.resource !== 'string') { + return res.sendStatus(400); + } + + const resourceLower = req.query.resource.toLowerCase(); + const webPrefix = config.url.toLowerCase() + '/@'; + let acctLower; + + if (resourceLower.startsWith(webPrefix)) { + acctLower = resourceLower.slice(webPrefix.length); + } else if (resourceLower.startsWith('acct:')) { + acctLower = resourceLower.slice('acct:'.length); + } else { + acctLower = resourceLower; + } + + const parsedAcctLower = parseAcct(acctLower); + if (![null, config.host.toLowerCase()].includes(parsedAcctLower.host)) { + return res.sendStatus(422); + } + + const user = await User.findOne({ usernameLower: parsedAcctLower.username, host: null }); + if (user === null) { + return res.sendStatus(404); + } + + return res.json({ + subject: `acct:${user.username}@${config.host}`, + links: [{ + rel: 'self', + type: 'application/activity+json', + href: `${config.url}/users/${user._id}` + }, { + rel: 'http://webfinger.net/rel/profile-page', + type: 'text/html', + href: `${config.url}/@${user.username}` + }, { + rel: 'http://ostatus.org/schema/1.0/subscribe', + template: `${config.url}/authorize-follow?acct={uri}` + }] + }); +}); + +export default app; |