mirror of
https://github.com/roleypoly/roleypoly-v1.git
synced 2025-06-16 10:19:10 +00:00
temp commit
This commit is contained in:
parent
e86f7ff68e
commit
6fb39d6c4d
22 changed files with 282 additions and 39 deletions
|
@ -10,7 +10,7 @@ import DiscordService from './services/discord'
|
|||
import SessionService from './services/sessions'
|
||||
import AuthService from './services/auth'
|
||||
import PresentationService from './services/presentation'
|
||||
import RPCServer from './rpc'
|
||||
import RPCServer from '@roleypoly/rpc-server'
|
||||
import fetchModels, { type Models } from './models'
|
||||
import fetchApis from './api'
|
||||
import retry from 'async-retry'
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@discordjs/uws": "^11.149.1",
|
||||
"@roleypoly/types": "^2.0.0",
|
||||
"@roleypoly/rpc-server": "^2.0.0",
|
||||
"@roleypoly/ui": "^2.0.0",
|
||||
"async-retry": "^1.2.3",
|
||||
"bufferutil": "^4.0.1",
|
||||
|
|
|
@ -1,59 +0,0 @@
|
|||
// @flow
|
||||
import logger from '../logger'
|
||||
import glob from 'glob'
|
||||
import path from 'path'
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
|
||||
const log = logger(__filename)
|
||||
const PROD: boolean = process.env.NODE_ENV === 'production'
|
||||
|
||||
export default (ctx: AppContext, forceClear: ?boolean = false): {
|
||||
[rpc: string]: Function
|
||||
} => {
|
||||
let map = {}
|
||||
const apis = glob.sync(`${__dirname}/**/!(index).js`).map(v => v.replace(__dirname, '.'))
|
||||
log.debug('found rpcs', apis)
|
||||
|
||||
for (let a of apis) {
|
||||
const filename = path.basename(a)
|
||||
const dirname = path.dirname(a)
|
||||
|
||||
const pathname = a
|
||||
delete require.cache[require.resolve(pathname)]
|
||||
|
||||
// internal stuff
|
||||
if (filename.startsWith('_')) {
|
||||
log.debug(`skipping ${a}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (dirname === 'client') {
|
||||
log.debug(`skipping ${a}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// testing only
|
||||
if (filename.endsWith('_test.js') && PROD) {
|
||||
log.debug(`skipping ${a}`)
|
||||
continue
|
||||
}
|
||||
|
||||
log.debug(`mounting ${a}`)
|
||||
try {
|
||||
const r = require(pathname)
|
||||
let o = r
|
||||
if (o.default) {
|
||||
o = r.default
|
||||
}
|
||||
|
||||
map = {
|
||||
...map,
|
||||
...o(ctx)
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(`couldn't mount ${a}`, e)
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
|
@ -1,181 +0,0 @@
|
|||
// @flow
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import { type Context } from 'koa'
|
||||
import RPCError from '@roleypoly/rpc-client/error'
|
||||
|
||||
import logger from '../logger'
|
||||
const log = logger(__filename)
|
||||
|
||||
const PermissionError = new RPCError('User does not have permission to call this RPC.', 403)
|
||||
|
||||
const logFacts = (
|
||||
ctx: Context,
|
||||
extra: { [x:string]: any } = {}
|
||||
) => ({
|
||||
fn: (ctx.request.body: any).fn,
|
||||
ip: ctx.ip,
|
||||
user: ctx.session.userId,
|
||||
...extra
|
||||
})
|
||||
|
||||
export const authed = (
|
||||
$: AppContext,
|
||||
fn: (ctx: Context, ...args: any[]) => any,
|
||||
silent: boolean = false
|
||||
) => async (
|
||||
ctx: Context,
|
||||
...args: any[]
|
||||
) => {
|
||||
if (await $.auth.isLoggedIn(ctx)) {
|
||||
return fn(ctx, ...args)
|
||||
}
|
||||
|
||||
if ($.config.dev) {
|
||||
log.debug('authed failed')
|
||||
throw new RPCError('User is not logged in', 403)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
log.info('RPC call authed check fail', logFacts(ctx))
|
||||
}
|
||||
|
||||
throw PermissionError
|
||||
}
|
||||
|
||||
export const root = (
|
||||
$: AppContext,
|
||||
fn: (ctx: Context, ...args: any[]) => any,
|
||||
silent: boolean = false
|
||||
) => authed($, (
|
||||
ctx: Context,
|
||||
...args: any[]
|
||||
) => {
|
||||
if ($.discord.isRoot(ctx.session.userId)) {
|
||||
return fn(ctx, ...args)
|
||||
}
|
||||
|
||||
if ($.config.dev) {
|
||||
log.debug('root failed')
|
||||
throw new RPCError('User is not root', 403)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
log.info('RPC call root check fail', logFacts(ctx))
|
||||
}
|
||||
|
||||
throw PermissionError
|
||||
})
|
||||
|
||||
export const manager = (
|
||||
$: AppContext,
|
||||
fn: (ctx: Context, server: string, ...args: any[]) => any,
|
||||
silent: boolean = false
|
||||
) => member($, (
|
||||
ctx: Context,
|
||||
server: string,
|
||||
...args: any[]
|
||||
) => {
|
||||
if ($.discord.canManageRoles(server, ctx.session.userId)) {
|
||||
return fn(ctx, server, ...args)
|
||||
}
|
||||
|
||||
if ($.config.dev) {
|
||||
log.debug('manager failed')
|
||||
throw new RPCError('User is not a role manager', 403)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
log.info('RPC call manager check fail', logFacts(ctx, { server }))
|
||||
}
|
||||
|
||||
throw PermissionError
|
||||
})
|
||||
|
||||
export const member = (
|
||||
$: AppContext,
|
||||
fn: (ctx: Context, server: string, ...args: any[]) => any,
|
||||
silent: boolean = false
|
||||
) => authed($, async (
|
||||
ctx: Context,
|
||||
server: string,
|
||||
...args: any[]
|
||||
) => {
|
||||
if (await $.discord.isMember(server, ctx.session.userId)) {
|
||||
return fn(ctx, server, ...args)
|
||||
}
|
||||
|
||||
if ($.config.dev) {
|
||||
log.debug('member failed')
|
||||
throw new RPCError('User is not a member of this server', 403)
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
log.info('RPC call member check fail', logFacts(ctx, { server }))
|
||||
}
|
||||
|
||||
throw PermissionError
|
||||
})
|
||||
|
||||
export const any = (
|
||||
$: AppContext,
|
||||
fn: (ctx: Context, ...args: any[]) => any,
|
||||
silent: boolean = false
|
||||
) => (...args: any) => fn(...args)
|
||||
|
||||
export const bot = (
|
||||
$: AppContext,
|
||||
fn: (ctx: Context, ...args: any[]) => any,
|
||||
silent: boolean = false
|
||||
) => (
|
||||
ctx: Context,
|
||||
...args: any
|
||||
) => {
|
||||
const authToken: ?string = ctx.request.headers['authorization']
|
||||
|
||||
if (authToken != null && authToken.startsWith('Bot ')) {
|
||||
if (authToken === `Bot ${$.config.sharedSecret}`) {
|
||||
return fn(ctx, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
log.info('RPC bot check failed', logFacts(ctx))
|
||||
}
|
||||
|
||||
throw PermissionError
|
||||
}
|
||||
|
||||
type Handler = (ctx: Context, ...args: any[]) => any
|
||||
type Strategy = (
|
||||
$: AppContext,
|
||||
fn: Handler,
|
||||
silent?: boolean
|
||||
) => any
|
||||
type StrategyPair = [ Strategy, Handler ]
|
||||
|
||||
/**
|
||||
* Weird func but ok -- test that a strategy doesn't fail, and run the first that doesn't.
|
||||
*/
|
||||
export const decide = (
|
||||
$: AppContext,
|
||||
...strategies: StrategyPair[]
|
||||
) => async (...args: any) => {
|
||||
for (let [ strat, handler ] of strategies) {
|
||||
if (strat === null) {
|
||||
strat = any
|
||||
}
|
||||
|
||||
try {
|
||||
return await strat($, handler, true)(...args)
|
||||
} catch (e) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// if we reach the end, just throw
|
||||
if ($.config.dev) {
|
||||
log.info('decide failed for', strategies.map(v => v[0].name))
|
||||
}
|
||||
|
||||
throw PermissionError
|
||||
}
|
|
@ -1,142 +0,0 @@
|
|||
// @flow
|
||||
import fnv from 'fnv-plus'
|
||||
import autoloader from './_autoloader'
|
||||
import RPCError from '@roleypoly/rpc-client/error'
|
||||
import type Roleypoly, { Router } from '../Roleypoly'
|
||||
import type { Context } from 'koa'
|
||||
// import logger from '../logger'
|
||||
// const log = logger(__filename)
|
||||
|
||||
export type RPCIncoming = {
|
||||
fn: string,
|
||||
args: any[]
|
||||
}
|
||||
|
||||
export type RPCOutgoing = {
|
||||
hash: string,
|
||||
response: any
|
||||
}
|
||||
|
||||
export default class RPCServer {
|
||||
ctx: Roleypoly
|
||||
|
||||
rpcMap: {
|
||||
[rpc: string]: Function
|
||||
}
|
||||
|
||||
mapHash: string
|
||||
rpcCalls: { name: string, args: number }[]
|
||||
|
||||
constructor (ctx: Roleypoly) {
|
||||
this.ctx = ctx
|
||||
this.reload()
|
||||
ctx.addRouteHook(this.hookRoutes)
|
||||
}
|
||||
|
||||
reload () {
|
||||
// actual function map
|
||||
this.rpcMap = autoloader(this.ctx.ctx)
|
||||
|
||||
// hash of the map
|
||||
// used for known-reloads in the client.
|
||||
this.mapHash = fnv.hash(Object.keys(this.rpcMap)).str()
|
||||
|
||||
// call map for the client.
|
||||
this.rpcCalls = Object.keys(this.rpcMap).map(fn => ({ name: fn, args: 0 }))
|
||||
}
|
||||
|
||||
hookRoutes = (router: Router) => {
|
||||
// RPC call reporter.
|
||||
// this is NEVER called in prod.
|
||||
// it is used to generate errors if RPC calls don't exist or are malformed in dev.
|
||||
router.get('/api/_rpc', async (ctx: Context) => {
|
||||
ctx.body = ({
|
||||
hash: this.mapHash,
|
||||
available: this.rpcCalls
|
||||
}: any)
|
||||
ctx.status = 200
|
||||
return true
|
||||
})
|
||||
|
||||
router.post('/api/_rpc', this.handleRPC)
|
||||
}
|
||||
|
||||
handleRPC = async (ctx: Context) => {
|
||||
// handle an impossible situation
|
||||
if (!(ctx.request.body instanceof Object)) {
|
||||
return this.rpcError(ctx, null, new RPCError('RPC format was very incorrect.', 400))
|
||||
}
|
||||
|
||||
// check if RPC exists
|
||||
const { fn, args } = (ctx.request.body: RPCIncoming)
|
||||
|
||||
if (!(fn in this.rpcMap)) {
|
||||
return this.rpcError(ctx, null, new RPCError(`RPC call ${fn}(...) not found.`, 404))
|
||||
}
|
||||
|
||||
const call = this.rpcMap[fn]
|
||||
|
||||
// if call.length (which is the solid args list)
|
||||
// is longer than args, we have too little to call the function.
|
||||
// if (args.length < call.length) {
|
||||
// return this.rpcError(ctx, null, new RPCError(`RPC call ${fn}() with ${args.length} arguments does not exist.`, 400))
|
||||
// }
|
||||
|
||||
try {
|
||||
const response = await call(ctx, ...args)
|
||||
|
||||
ctx.body = {
|
||||
hash: this.mapHash,
|
||||
response
|
||||
}
|
||||
|
||||
ctx.status = 200
|
||||
} catch (err) {
|
||||
return this.rpcError(ctx, 'RPC call errored', err, 500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For internally called stuff, such as from a bot shard.
|
||||
*/
|
||||
async call (fn: string, ...args: any[]) {
|
||||
if (!(fn in this.rpcMap)) {
|
||||
throw new RPCError(`RPC call ${fn}(...) not found.`, 404)
|
||||
}
|
||||
|
||||
const call = this.rpcMap[fn]
|
||||
return call(...args)
|
||||
}
|
||||
|
||||
rpcError (ctx: Context & {body: any}, msg: ?string, err: ?Error = null, code: ?number = null) {
|
||||
// log.error('rpc error', { msg, err })
|
||||
|
||||
ctx.body = {
|
||||
msg: err?.message || msg,
|
||||
error: true
|
||||
}
|
||||
|
||||
ctx.status = code || 500
|
||||
if (err != null) {
|
||||
if (err instanceof RPCError || err.constructor.name === 'RPCError') {
|
||||
// $FlowFixMe
|
||||
ctx.status = err.code
|
||||
}
|
||||
}
|
||||
|
||||
// if (err != null && err.constructor.name === 'RPCError') {
|
||||
// console.log({ status: err.code })
|
||||
// // this is one of our own errors, so we have a lot of data on this.
|
||||
// ctx.status = err.code // || code || 500
|
||||
// ctx.body.msg = `${err.message || msg || 'RPC Error'}`
|
||||
// } else {
|
||||
// if (msg == null && err != null) {
|
||||
// ctx.body.msg = err.message
|
||||
// }
|
||||
|
||||
// // if not, just play cloudflare, say something was really weird, and move on.
|
||||
// ctx.status = code || 520
|
||||
// ctx.message = 'Unknown Error'
|
||||
// }
|
||||
}
|
||||
}
|
|
@ -2,18 +2,18 @@
|
|||
import { type AppContext } from '../Roleypoly'
|
||||
import { type Context } from 'koa'
|
||||
import { type Guild } from 'eris'
|
||||
import * as secureAs from './_security'
|
||||
import RPCError from '@roleypoly/rpc-client/error'
|
||||
import { secureAs } from '@roleypoly/rpc-server'
|
||||
import { RPCError } from '@roleypoly/rpc-client'
|
||||
|
||||
export default ($: AppContext) => ({
|
||||
|
||||
rootGetAllServers: secureAs.root($, (ctx: Context) => {
|
||||
return $.discord.client.guilds.map<{
|
||||
return $.discord.guilds.valueSeq().map<{
|
||||
url: string,
|
||||
name: string,
|
||||
members: number,
|
||||
roles: number
|
||||
}>((g: Guild) => ({ url: `${$.config.appUrl}/s/${g.id}`, name: g.name, members: g.memberCount, roles: g.roles.size }))
|
||||
}>((g: Guild) => ({ url: `${$.config.appUrl}/s/${g.id}`, name: g.name, members: g.members.size, roles: g.roles.size })).toJS()
|
||||
}),
|
||||
|
||||
getServerSlug (ctx: Context, id: string) {
|
||||
|
@ -40,5 +40,18 @@ export default ($: AppContext) => ({
|
|||
}
|
||||
|
||||
return $.P.presentableServer(srv, gm)
|
||||
}),
|
||||
|
||||
listOwnServers: secureAs.authed($, async (ctx: Context, id: string) => {
|
||||
const { userId } = (ctx.session: { userId: string })
|
||||
const srv = $.discord.getRelevantServers(userId)
|
||||
return $.P.presentableServers(srv, userId)
|
||||
}),
|
||||
|
||||
syncGuild: secureAs.bot($, async (ctx: Context, type: string, guildId: string) => {
|
||||
const g = await $.discord.guild(guildId, true)
|
||||
if (g != null && type === 'guildCreate') {
|
||||
$.server.ensure(g)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
@ -4,7 +4,7 @@ import type { AppContext } from '../Roleypoly'
|
|||
import Eris, { type Member, Role, type Guild, type Permission as ErisPermission } from 'eris'
|
||||
import LRU from 'lru-cache'
|
||||
// $FlowFixMe
|
||||
import { OrderedSet } from 'immutable'
|
||||
import { OrderedSet, OrderedMap } from 'immutable'
|
||||
import superagent from 'superagent'
|
||||
import type { AuthTokens } from './auth'
|
||||
import type { IFetcher } from './discord/types'
|
||||
|
@ -54,6 +54,8 @@ export type MemberExt = Member & {
|
|||
}
|
||||
|
||||
export default class DiscordService extends Service {
|
||||
static _guildExpiration = +(process.env.GUILD_INVALIDATION_TIME || 36e5)
|
||||
|
||||
ctx: AppContext
|
||||
client: Eris
|
||||
|
||||
|
@ -67,6 +69,8 @@ export default class DiscordService extends Service {
|
|||
|
||||
fetcher: IFetcher
|
||||
|
||||
guilds: OrderedMap<Guild> = OrderedMap<Guild>()
|
||||
_lastGuildFetch: number
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.ctx = ctx
|
||||
|
@ -88,14 +92,15 @@ export default class DiscordService extends Service {
|
|||
})
|
||||
|
||||
this.fetcher = new RestFetcher(this)
|
||||
this.fetchGuilds(true)
|
||||
}
|
||||
|
||||
isRoot (id: string): boolean {
|
||||
return this.cfg.rootUsers.has(id)
|
||||
}
|
||||
|
||||
getRelevantServers (user: string) {
|
||||
return this.client.guilds.filter(guild => guild.members.has(user))
|
||||
getRelevantServers (user: string): OrderedSet<Guild> {
|
||||
return this.guilds.filter(guild => guild.members.has(user))
|
||||
}
|
||||
|
||||
async gm (serverId: string, userId: string, { canFake = false }: { canFake: boolean } = {}): Promise<?MemberExt> {
|
||||
|
@ -280,6 +285,36 @@ export default class DiscordService extends Service {
|
|||
return `https://discordapp.com/oauth2/authorize?client_id=${this.cfg.clientId}&scope=bot&permissions=268435456`
|
||||
}
|
||||
|
||||
async fetchGuilds (force: boolean = false) {
|
||||
if (
|
||||
force ||
|
||||
this.guilds.isEmpty() ||
|
||||
this._lastGuildFetch + DiscordService._guildExpiration < Date.now()
|
||||
) {
|
||||
const g = await this.fetcher.getGuilds()
|
||||
this.guilds = OrderedMap(g.reduce((acc, g) => ({ ...acc, [g.id]: g }), {}))
|
||||
}
|
||||
}
|
||||
|
||||
async guild (id: string, invalidate: boolean = false): Promise<?Guild> {
|
||||
// fetch if needed
|
||||
await this.fetchGuilds()
|
||||
|
||||
// do we know about it?
|
||||
// (also don't get this if we're invalidating)
|
||||
if (invalidate === false && this.guilds.has(id)) {
|
||||
return this.guilds.get(id)
|
||||
}
|
||||
|
||||
// else let's fetch and cache.
|
||||
const g = await this.fetcher.getGuild(id)
|
||||
if (g != null) {
|
||||
this.guilds = this.guilds.set(g.id, g)
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
async issueChallenge (author: string) {
|
||||
// Create a challenge
|
||||
const chall = await this.ctx.auth.createDMChallenge(author)
|
||||
|
|
|
@ -4,6 +4,8 @@ import type DiscordSvc from '../discord'
|
|||
import type ErisClient, { User, Member, Guild } from 'eris'
|
||||
import LRU from 'lru-cache'
|
||||
import logger from '../../logger'
|
||||
// $FlowFixMe
|
||||
import { OrderedSet } from 'immutable'
|
||||
const log = logger(__filename)
|
||||
|
||||
export default class BotFetcher implements IFetcher {
|
||||
|
@ -71,4 +73,31 @@ export default class BotFetcher implements IFetcher {
|
|||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getGuilds = async (): Promise<Guild[]> => {
|
||||
const last: ?string = undefined
|
||||
const limit: number = 100
|
||||
let out = OrderedSet<Guild>()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// $FlowFixMe -- last is optional, but typedef isn't.
|
||||
const gl = await this.client.getRESTGuilds(limit, last)
|
||||
|
||||
out = out.union(gl)
|
||||
if (gl.length !== limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('getAllGuilds failed', e)
|
||||
throw e
|
||||
}
|
||||
|
||||
return out.toArray()
|
||||
}
|
||||
|
||||
invalidateGuild (id: string) {
|
||||
this.cache.del(`G:${id}`)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,4 +11,6 @@ export interface IFetcher {
|
|||
getGuild: (id: string) => Promise<?Guild>;
|
||||
|
||||
getMember: (server: string, user: string) => Promise<?Member>;
|
||||
|
||||
getGuilds: () => Promise<Guild[]>;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ import { type AppContext } from '../Roleypoly'
|
|||
import { type Models } from '../models'
|
||||
import { type ServerModel } from '../models/Server'
|
||||
import type DiscordService from './discord'
|
||||
// $FlowFixMe
|
||||
import type { Sequence } from 'immutable'
|
||||
import {
|
||||
type Guild,
|
||||
type Collection
|
||||
|
@ -38,7 +40,7 @@ class PresentationService extends Service {
|
|||
}
|
||||
}
|
||||
|
||||
presentableServers (collection: Collection<Guild>, userId: string) {
|
||||
presentableServers (collection: Collection<Guild> | Sequence<Guild>, userId: string): Promise<PresentableServer[]> {
|
||||
return areduce(Array.from(collection.values()), async (acc, server) => {
|
||||
const gm = server.members.get(userId)
|
||||
if (gm == null) {
|
||||
|
|
|
@ -5,10 +5,10 @@ import type PresentationService from './presentation'
|
|||
import {
|
||||
type Guild
|
||||
} from 'eris'
|
||||
import { type ServerModel, type Category } from '../models/Server'
|
||||
import { type ServerModel, type Category } from '@roleypoly/types'
|
||||
|
||||
export default class ServerService extends Service {
|
||||
Server: any // Model<ServerModel> but flowtype is bugged
|
||||
Server: * // Model<ServerModel> but flowtype is bugged
|
||||
P: PresentationService
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue