ストリーム経由でAPIにリクエストできるように

This commit is contained in:
syuilo 2018-04-11 17:40:01 +09:00
parent 57edfa980f
commit 99774413f4
15 changed files with 137 additions and 179 deletions

View File

@ -444,23 +444,28 @@ export default class MiOS extends EventEmitter {
// Append a credential // Append a credential
if (this.isSignedIn) (data as any).i = this.i.token; if (this.isSignedIn) (data as any).i = this.i.token;
// TODO const viaStream = localStorage.getItem('enableExperimental') == 'true';
//const viaStream = localStorage.getItem('enableExperimental') == 'true';
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
/*if (viaStream) { if (viaStream) {
const stream = this.stream.borrow(); const stream = this.stream.borrow();
const id = Math.random().toString(); const id = Math.random().toString();
stream.once(`api-res:${id}`, res => { stream.once(`api-res:${id}`, res => {
resolve(res); if (res.res) {
resolve(res.res);
} else {
reject(res.e);
}
}); });
stream.send({ stream.send({
type: 'api', type: 'api',
id, id,
endpoint, endpoint,
data data
}); });
} else {*/ } else {
const req = { const req = {
id: uuid(), id: uuid(),
date: new Date(), date: new Date(),

View File

@ -19,7 +19,7 @@ export type IApp = {
nameId: string; nameId: string;
nameIdLower: string; nameIdLower: string;
description: string; description: string;
permission: string; permission: string[];
callbackUrl: string; callbackUrl: string;
}; };

View File

@ -2,55 +2,33 @@ import * as express from 'express';
import { Endpoint } from './endpoints'; import { Endpoint } from './endpoints';
import authenticate from './authenticate'; import authenticate from './authenticate';
import { IAuthContext } from './authenticate'; import call from './call';
import _reply from './reply'; import { IUser } from '../../models/user';
import limitter from './limitter'; import { IApp } from '../../models/app';
export default async (endpoint: Endpoint, req: express.Request, res: express.Response) => { export default async (endpoint: Endpoint, req: express.Request, res: express.Response) => {
const reply = _reply.bind(null, res); const reply = (x?: any, y?: any) => {
let ctx: IAuthContext; 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 // Authentication
try { try {
ctx = await authenticate(req); [user, app] = await authenticate(req.body['i']);
} catch (e) { } catch (e) {
return reply(403, 'AUTHENTICATION_FAILED'); return reply(403, 'AUTHENTICATION_FAILED');
} }
if (endpoint.secure && !ctx.isSecure) {
return reply(403, 'ACCESS_DENIED');
}
if (endpoint.withCredential && ctx.user == null) {
return reply(401, 'PLZ_SIGNIN');
}
if (ctx.app && endpoint.kind) {
if (!ctx.app.permission.some(p => p === endpoint.kind)) {
return reply(403, 'ACCESS_DENIED');
}
}
if (endpoint.withCredential && endpoint.limit) {
try {
await limitter(endpoint, ctx); // Rate limit
} catch (e) {
// drop request if limit exceeded
return reply(429);
}
}
let exec = require(`${__dirname}/endpoints/${endpoint.name}`);
if (endpoint.withFile) {
exec = exec.bind(null, req.file);
}
// API invoking // API invoking
try { call(endpoint, user, app, req.body, req).then(reply).catch(e => reply(400, e));
const res = await exec(req.body, ctx.user, ctx.app, ctx.isSecure);
reply(res);
} catch (e) {
reply(400, e);
}
}; };

View File

@ -1,50 +1,24 @@
import * as express from 'express'; import App, { IApp } from '../../models/app';
import App from '../../models/app';
import { default as User, IUser } from '../../models/user'; import { default as User, IUser } from '../../models/user';
import AccessToken from '../../models/access-token'; import AccessToken from '../../models/access-token';
import isNativeToken from './common/is-native-token'; import isNativeToken from './common/is-native-token';
export interface IAuthContext { export default (token: string) => new Promise<[IUser, IApp]>(async (resolve, reject) => {
/**
* App which requested
*/
app: any;
/**
* Authenticated user
*/
user: IUser;
/**
* Whether requested with a User-Native Token
*/
isSecure: boolean;
}
export default (req: express.Request) => new Promise<IAuthContext>(async (resolve, reject) => {
const token = req.body['i'] as string;
if (token == null) { if (token == null) {
return resolve({ resolve([null, null]);
app: null, return;
user: null,
isSecure: false
});
} }
if (isNativeToken(token)) { if (isNativeToken(token)) {
// Fetch user
const user: IUser = await User const user: IUser = await User
.findOne({ 'token': token }); .findOne({ token });
if (user === null) { if (user === null) {
return reject('user not found'); return reject('user not found');
} }
return resolve({ resolve([user, null]);
app: null,
user: user,
isSecure: true
});
} else { } else {
const accessToken = await AccessToken.findOne({ const accessToken = await AccessToken.findOne({
hash: token.toLowerCase() hash: token.toLowerCase()
@ -60,10 +34,6 @@ export default (req: express.Request) => new Promise<IAuthContext>(async (resolv
const user = await User const user = await User
.findOne({ _id: accessToken.userId }); .findOne({ _id: accessToken.userId });
return resolve({ resolve([user, app]);
app: app,
user: user,
isSecure: false
});
} }
}); });

55
src/server/api/call.ts Normal file
View File

@ -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);
});

View File

@ -36,14 +36,10 @@ import App, { pack } from '../../../../models/app';
/** /**
* Show an app * Show an app
*
* @param {any} params
* @param {any} user
* @param {any} _
* @param {any} isSecure
* @return {Promise<any>}
*/ */
module.exports = (params, user, _, isSecure) => new Promise(async (res, rej) => { module.exports = (params, user, app) => new Promise(async (res, rej) => {
const isSecure = user != null && app == null;
// Get 'appId' parameter // Get 'appId' parameter
const [appId, appIdErr] = $(params.appId).optional.id().$; const [appId, appIdErr] = $(params.appId).optional.id().$;
if (appIdErr) return rej('invalid appId param'); if (appIdErr) return rej('invalid appId param');
@ -57,16 +53,16 @@ module.exports = (params, user, _, isSecure) => new Promise(async (res, rej) =>
} }
// Lookup app // Lookup app
const app = appId !== undefined const ap = appId !== undefined
? await App.findOne({ _id: appId }) ? await App.findOne({ _id: appId })
: await App.findOne({ nameIdLower: nameId.toLowerCase() }); : await App.findOne({ nameIdLower: nameId.toLowerCase() });
if (app === null) { if (ap === null) {
return rej('app not found'); return rej('app not found');
} }
// Send response // Send response
res(await pack(app, user, { res(await pack(ap, user, {
includeSecret: isSecure && app.userId.equals(user._id) includeSecret: isSecure && ap.userId.equals(user._id)
})); }));
}); });

View File

@ -6,7 +6,9 @@ import User, { pack } from '../../../models/user';
/** /**
* Show myself * Show myself
*/ */
module.exports = (params, user, _, isSecure) => new Promise(async (res, rej) => { module.exports = (params, user, app) => new Promise(async (res, rej) => {
const isSecure = user != null && app == null;
// Serialize // Serialize
res(await pack(user, user, { res(await pack(user, user, {
detail: true, detail: true,

View File

@ -7,14 +7,10 @@ import event from '../../../../publishers/stream';
/** /**
* Update myself * Update myself
*
* @param {any} params
* @param {any} user
* @param {any} _
* @param {boolean} isSecure
* @return {Promise<any>}
*/ */
module.exports = async (params, user, _, isSecure) => new Promise(async (res, rej) => { module.exports = async (params, user, app) => new Promise(async (res, rej) => {
const isSecure = user != null && app == null;
// Get 'name' parameter // Get 'name' parameter
const [name, nameErr] = $(params.name).optional.nullable.string().pipe(isValidName).$; const [name, nameErr] = $(params.name).optional.nullable.string().pipe(isValidName).$;
if (nameErr) return rej('invalid name param'); if (nameErr) return rej('invalid name param');

View File

@ -35,9 +35,6 @@ import Meta from '../../../models/meta';
/** /**
* Show core info * Show core info
*
* @param {any} params
* @return {Promise<any>}
*/ */
module.exports = (params) => new Promise(async (res, rej) => { module.exports = (params) => new Promise(async (res, rej) => {
const meta: any = (await Meta.findOne()) || {}; const meta: any = (await Meta.findOne()) || {};

View File

@ -6,14 +6,8 @@ import Subscription from '../../../../models/sw-subscription';
/** /**
* subscribe service worker * subscribe service worker
*
* @param {any} params
* @param {any} user
* @param {any} _
* @param {boolean} isSecure
* @return {Promise<any>}
*/ */
module.exports = async (params, user, _, isSecure) => new Promise(async (res, rej) => { module.exports = async (params, user, app) => new Promise(async (res, rej) => {
// Get 'endpoint' parameter // Get 'endpoint' parameter
const [endpoint, endpointErr] = $(params.endpoint).string().$; const [endpoint, endpointErr] = $(params.endpoint).string().$;
if (endpointErr) return rej('invalid endpoint param'); if (endpointErr) return rej('invalid endpoint param');

View File

@ -7,7 +7,6 @@ import * as bodyParser from 'body-parser';
import * as cors from 'cors'; import * as cors from 'cors';
import * as multer from 'multer'; import * as multer from 'multer';
// import authenticate from './authenticate';
import endpoints from './endpoints'; import endpoints from './endpoints';
/** /**

View File

@ -2,12 +2,12 @@ import * as Limiter from 'ratelimiter';
import * as debug from 'debug'; import * as debug from 'debug';
import limiterDB from '../../db/redis'; import limiterDB from '../../db/redis';
import { Endpoint } from './endpoints'; import { Endpoint } from './endpoints';
import { IAuthContext } from './authenticate';
import getAcct from '../../acct/render'; import getAcct from '../../acct/render';
import { IUser } from '../../models/user';
const log = debug('misskey:limitter'); const log = debug('misskey:limitter');
export default (endpoint: Endpoint, ctx: IAuthContext) => new Promise((ok, reject) => { export default (endpoint: Endpoint, user: IUser) => new Promise((ok, reject) => {
const limitation = endpoint.limit; const limitation = endpoint.limit;
const key = limitation.hasOwnProperty('key') const key = limitation.hasOwnProperty('key')
@ -32,7 +32,7 @@ export default (endpoint: Endpoint, ctx: IAuthContext) => new Promise((ok, rejec
// Short-term limit // Short-term limit
function min() { function min() {
const minIntervalLimiter = new Limiter({ const minIntervalLimiter = new Limiter({
id: `${ctx.user._id}:${key}:min`, id: `${user._id}:${key}:min`,
duration: limitation.minInterval, duration: limitation.minInterval,
max: 1, max: 1,
db: limiterDB db: limiterDB
@ -43,7 +43,7 @@ export default (endpoint: Endpoint, ctx: IAuthContext) => new Promise((ok, rejec
return reject('ERR'); return reject('ERR');
} }
log(`@${getAcct(ctx.user)} ${endpoint.name} min remaining: ${info.remaining}`); log(`@${getAcct(user)} ${endpoint.name} min remaining: ${info.remaining}`);
if (info.remaining === 0) { if (info.remaining === 0) {
reject('BRIEF_REQUEST_INTERVAL'); reject('BRIEF_REQUEST_INTERVAL');
@ -60,7 +60,7 @@ export default (endpoint: Endpoint, ctx: IAuthContext) => new Promise((ok, rejec
// Long term limit // Long term limit
function max() { function max() {
const limiter = new Limiter({ const limiter = new Limiter({
id: `${ctx.user._id}:${key}`, id: `${user._id}:${key}`,
duration: limitation.duration, duration: limitation.duration,
max: limitation.max, max: limitation.max,
db: limiterDB db: limiterDB
@ -71,7 +71,7 @@ export default (endpoint: Endpoint, ctx: IAuthContext) => new Promise((ok, rejec
return reject('ERR'); return reject('ERR');
} }
log(`@${getAcct(ctx.user)} ${endpoint.name} max remaining: ${info.remaining}`); log(`@${getAcct(user)} ${endpoint.name} max remaining: ${info.remaining}`);
if (info.remaining === 0) { if (info.remaining === 0) {
reject('RATE_LIMIT_EXCEEDED'); reject('RATE_LIMIT_EXCEEDED');

View File

@ -1,13 +0,0 @@
import * as express from 'express';
export default (res: express.Response, x?: any, y?: any) => {
if (x === undefined) {
res.sendStatus(204);
} else if (typeof x === 'number') {
res.status(x).send({
error: x === 500 ? 'INTERNAL_ERROR' : y
});
} else {
res.send(x);
}
};

View File

@ -2,14 +2,22 @@ import * as websocket from 'websocket';
import * as redis from 'redis'; import * as redis from 'redis';
import * as debug from 'debug'; import * as debug from 'debug';
import User from '../../../models/user'; import User, { IUser } from '../../../models/user';
import Mute from '../../../models/mute'; import Mute from '../../../models/mute';
import { pack as packNote } from '../../../models/note'; import { pack as packNote } from '../../../models/note';
import readNotification from '../common/read-notification'; import readNotification from '../common/read-notification';
import call from '../call';
import { IApp } from '../../../models/app';
const log = debug('misskey'); const log = debug('misskey');
export default async function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any) { export default async function(
request: websocket.request,
connection: websocket.connection,
subscriber: redis.RedisClient,
user: IUser,
app: IApp
) {
// Subscribe Home stream channel // Subscribe Home stream channel
subscriber.subscribe(`misskey:user-stream:${user._id}`); subscriber.subscribe(`misskey:user-stream:${user._id}`);
@ -67,7 +75,17 @@ export default async function(request: websocket.request, connection: websocket.
switch (msg.type) { switch (msg.type) {
case 'api': case 'api':
// TODO 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; break;
case 'alive': case 'alive':

View File

@ -2,9 +2,6 @@ import * as http from 'http';
import * as websocket from 'websocket'; import * as websocket from 'websocket';
import * as redis from 'redis'; import * as redis from 'redis';
import config from '../../config'; import config from '../../config';
import { default as User, IUser } from '../../models/user';
import AccessToken from '../../models/access-token';
import isNativeToken from './common/is-native-token';
import homeStream from './stream/home'; import homeStream from './stream/home';
import driveStream from './stream/drive'; import driveStream from './stream/drive';
@ -16,6 +13,7 @@ import serverStream from './stream/server';
import requestsStream from './stream/requests'; import requestsStream from './stream/requests';
import channelStream from './stream/channel'; import channelStream from './stream/channel';
import { ParsedUrlQuery } from 'querystring'; import { ParsedUrlQuery } from 'querystring';
import authenticate from './authenticate';
module.exports = (server: http.Server) => { module.exports = (server: http.Server) => {
/** /**
@ -53,7 +51,7 @@ module.exports = (server: http.Server) => {
} }
const q = request.resourceURL.query as ParsedUrlQuery; const q = request.resourceURL.query as ParsedUrlQuery;
const user = await authenticate(q.i as string); const [user, app] = await authenticate(q.i as string);
if (request.resourceURL.pathname === '/othello-game') { if (request.resourceURL.pathname === '/othello-game') {
othelloGameStream(request, connection, subscriber, user); othelloGameStream(request, connection, subscriber, user);
@ -75,46 +73,9 @@ module.exports = (server: http.Server) => {
null; null;
if (channel !== null) { if (channel !== null) {
channel(request, connection, subscriber, user); channel(request, connection, subscriber, user, app);
} else { } else {
connection.close(); connection.close();
} }
}); });
}; };
/**
*
* @param token
*/
function authenticate(token: string): Promise<IUser> {
if (token == null) {
return Promise.resolve(null);
}
return new Promise(async (resolve, reject) => {
if (isNativeToken(token)) {
// Fetch user
const user: IUser = await User
.findOne({
host: null,
'token': token
});
resolve(user);
} else {
const accessToken = await AccessToken.findOne({
hash: token
});
if (accessToken == null) {
return reject('invalid signature');
}
// Fetch user
const user: IUser = await User
.findOne({ _id: accessToken.userId });
resolve(user);
}
});
}