mirror of
https://github.com/roleypoly/roleypoly-v1.git
synced 2025-06-16 10:19:10 +00:00
lerna: split up bulk of packages
This commit is contained in:
parent
cb0b1d2410
commit
47a2e5694e
90 changed files with 0 additions and 0 deletions
188
packages/roleypoly-server/Roleypoly.js
Normal file
188
packages/roleypoly-server/Roleypoly.js
Normal file
|
@ -0,0 +1,188 @@
|
|||
// @flow
|
||||
import Sequelize from 'sequelize'
|
||||
import Next from 'next'
|
||||
import betterRouter from 'koa-better-router'
|
||||
import type EventEmitter from 'events'
|
||||
import fs from 'fs'
|
||||
import logger from './logger'
|
||||
import ServerService from './services/server'
|
||||
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 fetchModels, { type Models } from './models'
|
||||
import fetchApis from './api'
|
||||
|
||||
import type SocketIO from 'socket.io'
|
||||
import type KoaApp, { Context } from 'koa'
|
||||
|
||||
const log = logger(__filename)
|
||||
|
||||
type HTTPHandler = (path: string, handler: (ctx: Context, next: () => void) => any) => void
|
||||
export type Router = {
|
||||
get: HTTPHandler,
|
||||
post: HTTPHandler,
|
||||
patch: HTTPHandler,
|
||||
delete: HTTPHandler,
|
||||
put: HTTPHandler,
|
||||
middleware: () => any
|
||||
}
|
||||
|
||||
export type RouteHook = (router: Router) => void
|
||||
|
||||
export type AppContext = {
|
||||
config: {
|
||||
appUrl: string,
|
||||
dev: boolean,
|
||||
hotReload: boolean
|
||||
},
|
||||
ui: Next,
|
||||
uiHandler: Next.Handler,
|
||||
io: SocketIO,
|
||||
|
||||
server: ServerService,
|
||||
discord: DiscordService,
|
||||
sessions: SessionService,
|
||||
P: PresentationService,
|
||||
RPC: RPCServer,
|
||||
M: Models,
|
||||
sql: Sequelize,
|
||||
auth: AuthService
|
||||
}
|
||||
|
||||
class Roleypoly {
|
||||
ctx: AppContext|any
|
||||
io: SocketIO
|
||||
router: Router
|
||||
|
||||
M: Models
|
||||
|
||||
__app: KoaApp
|
||||
__initialized: Promise<void>
|
||||
__apiWatcher: EventEmitter
|
||||
__rpcWatcher: EventEmitter
|
||||
|
||||
__routeHooks: Set<RouteHook> = new Set()
|
||||
constructor (io: SocketIO, app: KoaApp) {
|
||||
this.io = io
|
||||
this.__app = app
|
||||
|
||||
if (log.debugOn) log.warn('debug mode is on')
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production'
|
||||
|
||||
// simple check if we're in a compiled situation or not.
|
||||
let uiDir = './ui'
|
||||
if (!fs.existsSync(uiDir) && fs.existsSync('../ui')) {
|
||||
uiDir = '../ui'
|
||||
}
|
||||
|
||||
const ui = Next({ dev, dir: uiDir })
|
||||
const uiHandler = ui.getRequestHandler()
|
||||
|
||||
const appUrl = process.env.APP_URL
|
||||
if (appUrl == null) {
|
||||
throw new Error('APP_URL was unset.')
|
||||
}
|
||||
|
||||
this.ctx = {
|
||||
config: {
|
||||
appUrl,
|
||||
dev,
|
||||
hotReload: process.env.NO_HOT_RELOAD !== '1'
|
||||
},
|
||||
io,
|
||||
ui,
|
||||
uiHandler
|
||||
}
|
||||
|
||||
this.__initialized = this._mountServices()
|
||||
}
|
||||
|
||||
async awaitServices () {
|
||||
await this.__initialized
|
||||
}
|
||||
|
||||
async _mountServices () {
|
||||
const dbUrl: ?string = process.env.DB_URL
|
||||
if (dbUrl == null) {
|
||||
throw log.fatal('DB_URL not set.')
|
||||
}
|
||||
|
||||
const sequelize = new Sequelize(dbUrl, { logging: log.sql.bind(log, log) })
|
||||
this.ctx.sql = sequelize
|
||||
this.M = fetchModels(sequelize)
|
||||
this.ctx.M = this.M
|
||||
if (!process.env.DB_NO_SYNC) {
|
||||
await sequelize.sync()
|
||||
}
|
||||
|
||||
this.ctx.server = new ServerService(this.ctx)
|
||||
this.ctx.discord = new DiscordService(this.ctx)
|
||||
this.ctx.sessions = new SessionService(this.ctx)
|
||||
this.ctx.auth = new AuthService(this.ctx)
|
||||
this.ctx.P = new PresentationService(this.ctx)
|
||||
this.ctx.RPC = new RPCServer(this)
|
||||
}
|
||||
|
||||
addRouteHook (hook: RouteHook) {
|
||||
this.__routeHooks.add(hook)
|
||||
}
|
||||
|
||||
hookServiceRoutes (router: Router) {
|
||||
for (let h of this.__routeHooks) {
|
||||
h(router)
|
||||
}
|
||||
}
|
||||
|
||||
async loadRoutes (forceClear: boolean = false) {
|
||||
await this.ctx.ui.prepare()
|
||||
|
||||
this.router = betterRouter().loadMethods()
|
||||
fetchApis(this.router, this.ctx, { forceClear })
|
||||
// this.ctx.RPC.hookRoutes(this.router)
|
||||
|
||||
this.hookServiceRoutes(this.router)
|
||||
|
||||
// after routing, add the * for ui handler
|
||||
this.router.get('*', async ctx => {
|
||||
await this.ctx.uiHandler(ctx.req, ctx.res)
|
||||
ctx.respond = false
|
||||
})
|
||||
|
||||
return this.router.middleware()
|
||||
}
|
||||
|
||||
async mountRoutes () {
|
||||
let mw = await this.loadRoutes()
|
||||
|
||||
if (this.ctx.config.dev && this.ctx.config.hotReload) {
|
||||
// hot-reloading system
|
||||
log.info('API hot-reloading is active.')
|
||||
const chokidar = require('chokidar')
|
||||
let hotMiddleware = mw
|
||||
|
||||
this.__apiWatcher = chokidar.watch('api/**')
|
||||
this.__apiWatcher.on('all', async (path) => {
|
||||
log.info('reloading APIs...', path)
|
||||
hotMiddleware = await this.loadRoutes(true)
|
||||
})
|
||||
|
||||
this.__rpcWatcher = chokidar.watch('rpc/**')
|
||||
this.__rpcWatcher.on('all', (path) => {
|
||||
log.info('reloading RPCs...', path)
|
||||
this.ctx.RPC.reload()
|
||||
})
|
||||
|
||||
// custom passthrough so we use a specially scoped middleware.
|
||||
mw = (ctx, next) => {
|
||||
return hotMiddleware(ctx, next)
|
||||
}
|
||||
}
|
||||
|
||||
this.__app.use(mw)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Roleypoly
|
171
packages/roleypoly-server/api/auth.js
Normal file
171
packages/roleypoly-server/api/auth.js
Normal file
|
@ -0,0 +1,171 @@
|
|||
// @flow
|
||||
import { type Context } from 'koa'
|
||||
import { type AppContext, type Router } from '../Roleypoly'
|
||||
import ksuid from 'ksuid'
|
||||
import logger from '../logger'
|
||||
import renderError from '../util/error'
|
||||
const log = logger(__filename)
|
||||
|
||||
export default (R: Router, $: AppContext) => {
|
||||
R.post('/api/auth/token', async (ctx: Context) => {
|
||||
const { token } = ((ctx.request.body: any): { token: string })
|
||||
|
||||
if (token == null || token === '') {
|
||||
ctx.body = { err: 'token_missing' }
|
||||
ctx.status = 400
|
||||
return
|
||||
}
|
||||
|
||||
console.log(ctx.session.expiresAt >= new Date(), ctx.session.expiresAt, new Date())
|
||||
|
||||
if (ctx.session.accessToken === undefined || ctx.session.expiresAt >= new Date()) {
|
||||
const data = await $.discord.initializeOAuth(token)
|
||||
ctx.session.accessToken = data.access_token
|
||||
ctx.session.refreshToken = data.refresh_token
|
||||
ctx.session.expiresAt = new Date() + ctx.expires_in
|
||||
}
|
||||
|
||||
const user = await $.discord.getUserFromToken(ctx.session.accessToken)
|
||||
ctx.session.userId = user.id
|
||||
ctx.session.avatarHash = user.avatar
|
||||
|
||||
ctx.body = {
|
||||
id: user.id,
|
||||
avatar: user.avatar,
|
||||
username: user.username,
|
||||
discriminator: user.discriminator
|
||||
}
|
||||
})
|
||||
|
||||
R.get('/api/auth/user', async (ctx: Context) => {
|
||||
const { accessToken } = ((ctx.session: any): { accessToken?: string })
|
||||
if (accessToken === undefined) {
|
||||
ctx.body = { err: 'not_logged_in' }
|
||||
ctx.status = 401
|
||||
return
|
||||
}
|
||||
|
||||
const user = await $.discord.getUserFromToken(accessToken)
|
||||
ctx.session.userId = user.id
|
||||
ctx.session.avatarHash = user.avatar
|
||||
|
||||
ctx.body = {
|
||||
id: user.id,
|
||||
avatar: user.avatar,
|
||||
username: user.username,
|
||||
discriminator: user.discriminator
|
||||
}
|
||||
})
|
||||
|
||||
R.get('/api/auth/redirect', async (ctx: Context) => {
|
||||
const { r } = ctx.query
|
||||
// check if already authed
|
||||
if (await $.auth.isLoggedIn(ctx, { refresh: true })) {
|
||||
log.debug('already authed.', ctx.session)
|
||||
return ctx.redirect(r || '/')
|
||||
}
|
||||
|
||||
ctx.session.oauthRedirect = r
|
||||
const url = $.discord.getAuthUrl(ksuid.randomSync().string)
|
||||
if (ctx.query.url === '✔️') {
|
||||
ctx.body = { url }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.redirect(url)
|
||||
})
|
||||
|
||||
R.get('/api/oauth/callback', async (ctx: Context, next: *) => {
|
||||
const { code, state } = ctx.query
|
||||
const { oauthRedirect: r } = ctx.session
|
||||
delete ctx.session.oauthRedirect
|
||||
if (await $.auth.isLoggedIn(ctx)) {
|
||||
log.debug('user was logged in')
|
||||
return ctx.redirect(r || '/')
|
||||
}
|
||||
|
||||
if (code == null) {
|
||||
ctx.status = 400
|
||||
await renderError($, ctx)
|
||||
return
|
||||
}
|
||||
|
||||
if (state != null) {
|
||||
try {
|
||||
const ksState = ksuid.parse(state)
|
||||
const fiveMinAgo = new Date() - 1000 * 60 * 5
|
||||
if (ksState.date < fiveMinAgo) {
|
||||
ctx.status = 419
|
||||
await renderError($, ctx)
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.status = 400
|
||||
await renderError($, ctx)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await $.discord.initializeOAuth(code)
|
||||
const user = await $.discord.getUserFromToken(tokens.access_token)
|
||||
$.auth.injectSessionFromOAuth(ctx, tokens, user.id)
|
||||
log.debug('user logged in', { tokens, user, s: ctx.session })
|
||||
return ctx.redirect(r || '/')
|
||||
} catch (e) {
|
||||
log.error('token and auth fetch failure', e)
|
||||
ctx.status = 400
|
||||
return renderError($, ctx)
|
||||
}
|
||||
})
|
||||
|
||||
R.post('/api/auth/logout', async (ctx: Context) => {
|
||||
ctx.session = null
|
||||
})
|
||||
|
||||
R.get('/api/auth/logout', async (ctx: Context) => {
|
||||
if (await $.auth.isLoggedIn(ctx)) {
|
||||
if (ctx.session.authType === 'oauth') {
|
||||
await $.discord.revokeOAuth(ctx.session)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.session = null
|
||||
return ctx.redirect('/')
|
||||
})
|
||||
|
||||
R.get('/api/oauth/bot', async (ctx: Context) => {
|
||||
const url = $.discord.getBotJoinUrl()
|
||||
if (ctx.query.url === '✔️') {
|
||||
ctx.body = { url }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.redirect(url)
|
||||
})
|
||||
|
||||
R.get('/api/oauth/bot/callback', async (ctx: Context) => {
|
||||
// console.log(ctx.request)
|
||||
})
|
||||
|
||||
R.get('/magic/:challenge', async (ctx: Context) => {
|
||||
if (ctx.request.headers['user-agent'].includes('Discordbot')) {
|
||||
return $.ui.render(ctx.req, ctx.res, '/_internal/_discordbot/_magic', {})
|
||||
}
|
||||
|
||||
const { challenge } = ((ctx.params: any): { challenge: string })
|
||||
const chall = await $.auth.fetchDMChallenge({ magic: challenge })
|
||||
// log.notice('magic user agent', { ua: ctx.request.headers['User-Agent'] })
|
||||
|
||||
if (chall == null) {
|
||||
log.warn('bad magic', challenge)
|
||||
return ctx.redirect('/auth/expired')
|
||||
}
|
||||
|
||||
$.auth.injectSessionFromChallenge(ctx, chall)
|
||||
await $.auth.deleteDMChallenge(chall)
|
||||
log.info('logged in via magic', chall)
|
||||
|
||||
return ctx.redirect('/')
|
||||
})
|
||||
}
|
32
packages/roleypoly-server/api/index.js
Normal file
32
packages/roleypoly-server/api/index.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
// @flow
|
||||
import logger from '../logger'
|
||||
import glob from 'glob'
|
||||
|
||||
import type { Router, AppContext } from '../Roleypoly'
|
||||
|
||||
const log = logger(__filename)
|
||||
|
||||
const PROD = process.env.NODE_ENV === 'production'
|
||||
|
||||
export default async (router: Router, ctx: AppContext, { forceClear = false }: { forceClear: boolean } = {}) => {
|
||||
const apis = glob.sync(`./api/**/!(index).js`)
|
||||
log.debug('found apis', apis)
|
||||
|
||||
for (let a of apis) {
|
||||
if (a.endsWith('_test.js') && PROD) {
|
||||
log.debug(`skipping ${a}`)
|
||||
continue
|
||||
}
|
||||
log.debug(`mounting ${a}`)
|
||||
try {
|
||||
const pathname = a.replace('api/', '')
|
||||
if (forceClear) {
|
||||
delete require.cache[require.resolve(pathname)]
|
||||
}
|
||||
// $FlowFixMe this isn't an important error. potentially dangerous, but irrelevant.
|
||||
require(pathname).default(router, ctx)
|
||||
} catch (e) {
|
||||
log.error(`couldn't mount ${a}`, e)
|
||||
}
|
||||
}
|
||||
}
|
137
packages/roleypoly-server/api/servers.js
Normal file
137
packages/roleypoly-server/api/servers.js
Normal file
|
@ -0,0 +1,137 @@
|
|||
// @flow
|
||||
import { type Context } from 'koa'
|
||||
import { type AppContext, type Router } from '../Roleypoly'
|
||||
import { type ServerModel } from '../models/Server'
|
||||
|
||||
export default (R: Router, $: AppContext) => {
|
||||
R.get('/api/servers', async (ctx: Context) => {
|
||||
try {
|
||||
const { userId } = ctx.session
|
||||
const srv = $.discord.getRelevantServers(userId)
|
||||
const presentable = await $.P.presentableServers(srv, userId)
|
||||
|
||||
ctx.body = presentable
|
||||
} catch (e) {
|
||||
console.error(e.trace || e.stack)
|
||||
}
|
||||
})
|
||||
|
||||
R.get('/api/server/:id', async (ctx: Context) => {
|
||||
const { userId } = ctx.session
|
||||
const { id } = ctx.params
|
||||
|
||||
const srv = $.discord.client.guilds.get(id)
|
||||
|
||||
if (srv == null) {
|
||||
ctx.body = { err: 'not found' }
|
||||
ctx.status = 404
|
||||
return
|
||||
}
|
||||
|
||||
let gm
|
||||
if (srv.members.has(userId)) {
|
||||
gm = $.discord.gm(id, userId)
|
||||
} else if ($.discord.isRoot(userId)) {
|
||||
gm = $.discord.fakeGm({ id: userId })
|
||||
}
|
||||
|
||||
if (gm == null) {
|
||||
ctx.body = { err: 'not_a_member' }
|
||||
ctx.status = 400
|
||||
return
|
||||
}
|
||||
|
||||
const server = await $.P.presentableServer(srv, gm)
|
||||
|
||||
// $FlowFixMe bad koa type
|
||||
ctx.body = server
|
||||
})
|
||||
|
||||
R.get('/api/server/:id/slug', async (ctx: Context) => {
|
||||
// const { userId } = ctx.session
|
||||
const { id } = ctx.params
|
||||
|
||||
const srv = $.discord.client.guilds.get(id)
|
||||
|
||||
console.log(srv)
|
||||
|
||||
if (srv == null) {
|
||||
ctx.body = { err: 'not found' }
|
||||
ctx.status = 404
|
||||
return
|
||||
}
|
||||
|
||||
// $FlowFixMe bad koa type
|
||||
ctx.body = await $.P.serverSlug(srv)
|
||||
})
|
||||
|
||||
R.patch('/api/server/:id', async (ctx: Context) => {
|
||||
const { userId } = (ctx.session: { userId: string })
|
||||
const { id } = (ctx.params: { id: string })
|
||||
|
||||
let gm = $.discord.gm(id, userId)
|
||||
if (gm == null) {
|
||||
if ($.discord.isRoot(userId)) {
|
||||
gm = $.discord.fakeGm({ id: userId })
|
||||
} else {
|
||||
ctx.status = 403
|
||||
ctx.body = {
|
||||
err: 'not permitted'
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// check perms
|
||||
if (!$.discord.getPermissions(gm).canManageRoles) {
|
||||
ctx.status = 403
|
||||
ctx.body = { err: 'cannot_manage_roles' }
|
||||
return
|
||||
}
|
||||
|
||||
const { message, categories } = ((ctx.request.body: any): $Shape<ServerModel>)
|
||||
|
||||
// todo make less nasty
|
||||
await $.server.update(id, {
|
||||
...((message != null) ? { message } : {}),
|
||||
...((categories != null) ? { categories } : {})
|
||||
})
|
||||
|
||||
ctx.body = { ok: true }
|
||||
})
|
||||
|
||||
R.patch('/api/servers/:server/roles', async (ctx: Context) => {
|
||||
const { userId } = (ctx.session: { userId: string })
|
||||
const { server } = (ctx.params: { server: string })
|
||||
|
||||
let gm = $.discord.gm(server, userId)
|
||||
if (gm == null) {
|
||||
if ($.discord.isRoot(userId)) {
|
||||
gm = $.discord.fakeGm({ id: userId })
|
||||
} else {
|
||||
ctx.status = 403
|
||||
ctx.body = {
|
||||
err: 'not permitted'
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const originalRoles = gm.roles
|
||||
let { added, removed } = ((ctx.request.body: any): { added: string[], removed: string[] })
|
||||
|
||||
const allowedRoles: string[] = await $.server.getAllowedRoles(server)
|
||||
|
||||
const isSafe = (r: string) => $.discord.safeRole(server, r) && allowedRoles.includes(r)
|
||||
|
||||
added = added.filter(isSafe)
|
||||
removed = removed.filter(isSafe)
|
||||
|
||||
const newRoles = originalRoles.concat(added).filter(x => !removed.includes(x))
|
||||
gm.edit({
|
||||
roles: newRoles
|
||||
})
|
||||
|
||||
ctx.body = { ok: true }
|
||||
})
|
||||
}
|
28
packages/roleypoly-server/api/servers_test.js
Normal file
28
packages/roleypoly-server/api/servers_test.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
// @flow
|
||||
import { type Context } from 'koa'
|
||||
import { type AppContext, type Router } from '../Roleypoly'
|
||||
export default (R: Router, $: AppContext) => {
|
||||
R.get('/api/~/relevant-servers/:user', async (ctx: Context, next: () => void) => {
|
||||
// ctx.body = 'ok'
|
||||
const srv = $.discord.getRelevantServers(ctx.params.user)
|
||||
ctx.body = $.P.presentableServers(srv, ctx.params.user)
|
||||
})
|
||||
|
||||
// R.get('/api/~/roles/:id/:userId', (ctx, next) => {
|
||||
// // ctx.body = 'ok'
|
||||
// const { id, userId } = ctx.params
|
||||
|
||||
// const srv = $.discord.client.guilds.get(id)
|
||||
|
||||
// if (srv === undefined) {
|
||||
// ctx.body = { err: 'not found' }
|
||||
// ctx.status = 404
|
||||
// return
|
||||
// }
|
||||
|
||||
// const gm = srv.members.get(userId)
|
||||
// const roles = $.P.presentableRoles(id, gm)
|
||||
|
||||
// ctx.boy = roles
|
||||
// })
|
||||
}
|
37
packages/roleypoly-server/api/ui.js
Normal file
37
packages/roleypoly-server/api/ui.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
// @flow
|
||||
import { type Context } from 'koa'
|
||||
import { type AppContext, type Router } from '../Roleypoly'
|
||||
export default (R: Router, $: AppContext) => {
|
||||
// note, this file only contains stuff for complicated routes.
|
||||
// next.js will handle anything beyond this.
|
||||
const processMappings = (mapping: { [path: string]: { path: string, noAutoFix?: boolean } }) => {
|
||||
for (let p in mapping) {
|
||||
R.get(p, (ctx: Context) => {
|
||||
ctx.status = 200
|
||||
return $.ui.render(ctx.req, ctx.res, mapping[p].path || mapping[p], { ...ctx.query, ...ctx.params })
|
||||
})
|
||||
|
||||
const { path } = mapping[p]
|
||||
if (!mapping[p].noAutoFix) {
|
||||
R.get(path, ctx => ctx.redirect(p))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processMappings({
|
||||
'/s/add': { path: '/_internal/_server_add' },
|
||||
'/s/:id': { path: '/_internal/_server', noAutoFix: true },
|
||||
'/test': { path: '/test_wwsw' }
|
||||
})
|
||||
|
||||
// edge cases
|
||||
R.get('/_internal/_server', (ctx: Context) => {
|
||||
if (ctx.query.id) {
|
||||
return ctx.redirect(`/s/${ctx.query.id}`)
|
||||
}
|
||||
|
||||
return ctx.redirect('/s/add')
|
||||
})
|
||||
|
||||
R.get('/s/', (ctx: Context) => ctx.redirect('/s/add'))
|
||||
}
|
15
packages/roleypoly-server/bot/index.js
Normal file
15
packages/roleypoly-server/bot/index.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
// @flow
|
||||
import type DiscordService from '../services/discord'
|
||||
import logger from '../logger'
|
||||
const log = logger(__filename)
|
||||
|
||||
export default class Bot {
|
||||
svc: DiscordService
|
||||
log: typeof log
|
||||
constructor (DS: DiscordService) {
|
||||
this.svc = DS
|
||||
this.log = log
|
||||
}
|
||||
|
||||
|
||||
}
|
81
packages/roleypoly-server/index.js
Normal file
81
packages/roleypoly-server/index.js
Normal file
|
@ -0,0 +1,81 @@
|
|||
// @flow
|
||||
import 'dotenv/config'
|
||||
import logger from './logger'
|
||||
import http from 'http'
|
||||
import Koa from 'koa'
|
||||
import SocketIO from 'socket.io'
|
||||
import Roleypoly from './Roleypoly'
|
||||
import ksuid from 'ksuid'
|
||||
import bodyParser from 'koa-bodyparser'
|
||||
import compress from 'kompression'
|
||||
import session from 'koa-session'
|
||||
import invariant from 'invariant'
|
||||
import Keygrip from 'keygrip'
|
||||
|
||||
const log = logger(__filename)
|
||||
const app = new Koa()
|
||||
|
||||
// Create the server and socket.io server
|
||||
const server = http.createServer(app.callback())
|
||||
const io = SocketIO(server, { transports: ['websocket'], path: '/api/socket.io' })
|
||||
|
||||
const M = new Roleypoly(io, app) // eslint-disable-line no-unused-vars
|
||||
|
||||
const appKey = process.env.APP_KEY
|
||||
if (appKey == null) {
|
||||
throw invariant(false, '')
|
||||
}
|
||||
app.keys = new Keygrip([ appKey ])
|
||||
|
||||
const DEVEL = process.env.NODE_ENV === 'development'
|
||||
|
||||
async function start () {
|
||||
await M.awaitServices()
|
||||
|
||||
// body parser
|
||||
app.use(bodyParser())
|
||||
|
||||
// Compress
|
||||
app.use(compress())
|
||||
|
||||
// Request logger
|
||||
app.use(async (ctx, next) => {
|
||||
let timeStart = new Date()
|
||||
try {
|
||||
await next()
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
ctx.status = ctx.status || 500
|
||||
if (DEVEL) {
|
||||
ctx.body = ctx.body || e.stack
|
||||
} else {
|
||||
ctx.body = {
|
||||
err: 'something terrible happened.'
|
||||
}
|
||||
}
|
||||
}
|
||||
let timeElapsed = new Date() - timeStart
|
||||
|
||||
log.request(`${ctx.status} ${ctx.method} ${ctx.url} - ${ctx.ip} - took ${timeElapsed}ms`)
|
||||
// return null
|
||||
})
|
||||
|
||||
app.use(session({
|
||||
key: 'roleypoly:sess',
|
||||
maxAge: 'session',
|
||||
siteOnly: true,
|
||||
store: M.ctx.sessions,
|
||||
genid: () => { return ksuid.randomSync().string }
|
||||
}, app))
|
||||
|
||||
await M.mountRoutes()
|
||||
|
||||
// SPA server
|
||||
|
||||
log.info(`starting HTTP server on ${process.env.APP_PORT || 6769}`)
|
||||
server.listen(process.env.APP_PORT || 6769)
|
||||
}
|
||||
|
||||
start().catch(e => {
|
||||
log.fatal('app failed to start', e)
|
||||
})
|
66
packages/roleypoly-server/logger.js
Normal file
66
packages/roleypoly-server/logger.js
Normal file
|
@ -0,0 +1,66 @@
|
|||
// @flow
|
||||
import chalk from 'chalk'
|
||||
|
||||
export class Logger {
|
||||
debugOn: boolean
|
||||
name: string
|
||||
quietSql: boolean
|
||||
|
||||
constructor (name: string, debugOverride: boolean = false) {
|
||||
this.name = name
|
||||
this.debugOn = (process.env.DEBUG === 'true' || process.env.DEBUG === '*') || debugOverride
|
||||
this.quietSql = (process.env.DEBUG_SQL !== 'true')
|
||||
}
|
||||
|
||||
fatal (text: string, ...data: any) {
|
||||
this.error(text, data)
|
||||
|
||||
if (typeof data[data.length - 1] === 'number') {
|
||||
process.exit(data[data.length - 1])
|
||||
} else {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
error (text: string, ...data: any) {
|
||||
console.error(chalk.red.bold(`ERR ${this.name}:`) + `\n ${text}`, data)
|
||||
}
|
||||
|
||||
warn (text: string, ...data: any) {
|
||||
console.warn(chalk.yellow.bold(`WARN ${this.name}:`) + `\n ${text}`, data)
|
||||
}
|
||||
|
||||
notice (text: string, ...data: any) {
|
||||
console.log(chalk.cyan.bold(`NOTICE ${this.name}:`) + `\n ${text}`, data)
|
||||
}
|
||||
|
||||
info (text: string, ...data: any) {
|
||||
console.info(chalk.blue.bold(`INFO ${this.name}:`) + `\n ${text}`, data)
|
||||
}
|
||||
|
||||
deprecated (text: string, ...data: any) {
|
||||
console.warn(chalk.yellowBright(`DEPRECATED ${this.name}:`) + `\n ${text}`, data)
|
||||
console.trace()
|
||||
}
|
||||
|
||||
request (text: string, ...data: any) {
|
||||
console.info(chalk.green.bold(`HTTP ${this.name}:`) + `\n ${text}`)
|
||||
}
|
||||
|
||||
debug (text: string, ...data: any) {
|
||||
if (this.debugOn) {
|
||||
console.log(chalk.gray.bold(`DEBUG ${this.name}:`) + `\n ${text}`, data)
|
||||
}
|
||||
}
|
||||
|
||||
sql (logger: Logger, ...data: any) {
|
||||
if (logger.debugOn && !logger.quietSql) {
|
||||
console.log(chalk.bold('DEBUG SQL:\n '), data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default (pathname: string) => {
|
||||
const name = pathname.replace(__dirname, '').replace('.js', '')
|
||||
return new Logger(name)
|
||||
}
|
14
packages/roleypoly-server/models/AuthChallenge.js
Normal file
14
packages/roleypoly-server/models/AuthChallenge.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
// @flow
|
||||
import type Sequelize, { DataTypes as DT } from 'sequelize'
|
||||
|
||||
export default (sql: Sequelize, DataTypes: DT) => {
|
||||
return sql.define('auth_challenge', {
|
||||
userId: DataTypes.TEXT,
|
||||
issuedAt: DataTypes.DATE,
|
||||
type: DataTypes.ENUM('dm', 'other'),
|
||||
human: { type: DataTypes.TEXT, unique: true },
|
||||
magic: { type: DataTypes.TEXT, unique: true }
|
||||
}, {
|
||||
indexes: [ { fields: ['userId'] } ]
|
||||
})
|
||||
}
|
33
packages/roleypoly-server/models/Server.js
Normal file
33
packages/roleypoly-server/models/Server.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
// @flow
|
||||
import type Sequelize, { DataTypes as DT } from 'sequelize'
|
||||
|
||||
export type Category = {
|
||||
hidden: boolean,
|
||||
name: string,
|
||||
roles: string[],
|
||||
_roles?: any,
|
||||
type: 'single' | 'multi' | string
|
||||
}
|
||||
|
||||
export type ServerModel = {
|
||||
id: string,
|
||||
categories: {
|
||||
[uuid: string]: Category
|
||||
},
|
||||
message: string
|
||||
}
|
||||
|
||||
export default (sql: Sequelize, DataTypes: DT) => {
|
||||
return sql.define('server', {
|
||||
id: { // discord snowflake
|
||||
type: DataTypes.TEXT,
|
||||
primaryKey: true
|
||||
},
|
||||
categories: {
|
||||
type: DataTypes.JSON
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT
|
||||
}
|
||||
})
|
||||
}
|
7
packages/roleypoly-server/models/Session.js
Normal file
7
packages/roleypoly-server/models/Session.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
module.exports = (sequelize, DataTypes) => {
|
||||
return sequelize.define('session', {
|
||||
id: { type: DataTypes.TEXT, primaryKey: true },
|
||||
maxAge: DataTypes.BIGINT,
|
||||
data: DataTypes.JSONB
|
||||
})
|
||||
}
|
47
packages/roleypoly-server/models/index.js
Normal file
47
packages/roleypoly-server/models/index.js
Normal file
|
@ -0,0 +1,47 @@
|
|||
// @flow
|
||||
import logger from '../logger'
|
||||
import glob from 'glob'
|
||||
import path from 'path'
|
||||
import type Sequelize, { Model } from 'sequelize'
|
||||
|
||||
const log = logger(__filename)
|
||||
|
||||
export type Models = {
|
||||
[modelName: string]: Model<any> & {
|
||||
__associations: (models: Models) => void,
|
||||
__instanceMethods: (model: Model<any>) => void,
|
||||
}
|
||||
}
|
||||
|
||||
export default (sql: Sequelize): Models => {
|
||||
const models: Models = {}
|
||||
const modelFiles = glob.sync('./models/**/!(index).js')
|
||||
log.debug('found models', modelFiles)
|
||||
|
||||
modelFiles.forEach((v) => {
|
||||
let name = path.basename(v).replace('.js', '')
|
||||
if (v === './models/index.js') {
|
||||
log.debug('index.js hit, skipped')
|
||||
return
|
||||
}
|
||||
try {
|
||||
log.debug('importing..', v.replace('models/', ''))
|
||||
let model = sql.import(v.replace('models/', ''))
|
||||
models[name] = model
|
||||
} catch (err) {
|
||||
log.fatal('error importing model ' + v, err)
|
||||
process.exit(-1)
|
||||
}
|
||||
})
|
||||
|
||||
Object.keys(models).forEach((v) => {
|
||||
if (models[v].hasOwnProperty('__associations')) {
|
||||
models[v].__associations(models)
|
||||
}
|
||||
if (models[v].hasOwnProperty('__instanceMethods')) {
|
||||
models[v].__instanceMethods(models[v])
|
||||
}
|
||||
})
|
||||
|
||||
return models
|
||||
}
|
59
packages/roleypoly-server/rpc/_autoloader.js
Normal file
59
packages/roleypoly-server/rpc/_autoloader.js
Normal file
|
@ -0,0 +1,59 @@
|
|||
// @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(`./rpc/**/!(index).js`)
|
||||
log.debug('found rpcs', apis)
|
||||
|
||||
for (let a of apis) {
|
||||
const filename = path.basename(a)
|
||||
const dirname = path.dirname(a)
|
||||
|
||||
const pathname = a.replace('rpc/', '')
|
||||
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
|
||||
}
|
15
packages/roleypoly-server/rpc/_error.js
Normal file
15
packages/roleypoly-server/rpc/_error.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
class RPCError extends Error {
|
||||
constructor (msg, code, ...extra) {
|
||||
super(msg)
|
||||
this.code = code
|
||||
this.extra = extra
|
||||
}
|
||||
|
||||
static fromResponse (body, status) {
|
||||
const e = new RPCError(body.msg, status)
|
||||
e.remoteStack = body.trace
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RPCError
|
158
packages/roleypoly-server/rpc/_security.js
Normal file
158
packages/roleypoly-server/rpc/_security.js
Normal file
|
@ -0,0 +1,158 @@
|
|||
// @flow
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import { type Context } from 'koa'
|
||||
import RPCError from './_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($, (
|
||||
ctx: Context,
|
||||
server: string,
|
||||
...args: any[]
|
||||
) => {
|
||||
if ($.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)
|
||||
|
||||
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
|
||||
}
|
16
packages/roleypoly-server/rpc/auth.js
Normal file
16
packages/roleypoly-server/rpc/auth.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
// @flow
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import { type Context } from 'koa'
|
||||
|
||||
export default ($: AppContext) => ({
|
||||
async checkAuthChallenge (ctx: Context, text: string): Promise<boolean> {
|
||||
const chall = await $.auth.fetchDMChallenge({ human: text.toLowerCase() })
|
||||
if (chall == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
$.auth.injectSessionFromChallenge(ctx, chall)
|
||||
$.auth.deleteDMChallenge(chall)
|
||||
return true
|
||||
}
|
||||
})
|
143
packages/roleypoly-server/rpc/index.js
Normal file
143
packages/roleypoly-server/rpc/index.js
Normal file
|
@ -0,0 +1,143 @@
|
|||
// @flow
|
||||
import logger from '../logger'
|
||||
import fnv from 'fnv-plus'
|
||||
import autoloader from './_autoloader'
|
||||
import RPCError from './_error'
|
||||
import type Roleypoly, { Router } from '../Roleypoly'
|
||||
import type { Context } from 'koa'
|
||||
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
|
||||
console.log(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'
|
||||
// }
|
||||
}
|
||||
}
|
23
packages/roleypoly-server/rpc/rpc_test.js
Normal file
23
packages/roleypoly-server/rpc/rpc_test.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
// @flow
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import { type Context } from 'koa'
|
||||
import * as secureAs from './_security'
|
||||
export default ($: AppContext) => ({
|
||||
// i want RPC to be *dead* simple.
|
||||
// probably too simple.
|
||||
hello (_: Context, hello: string) {
|
||||
return `hello, ${hello} and all who inhabit it`
|
||||
},
|
||||
|
||||
testJSON (_: Context, inobj: {}) {
|
||||
return inobj
|
||||
},
|
||||
|
||||
testDecide: secureAs.decide($,
|
||||
[ secureAs.root, () => { return 'root' } ],
|
||||
[ secureAs.manager, () => { return 'manager' } ],
|
||||
[ secureAs.member, () => { return 'member' } ],
|
||||
[ secureAs.authed, () => { return 'authed' } ],
|
||||
[ secureAs.any, () => { return 'guest' } ]
|
||||
)
|
||||
})
|
48
packages/roleypoly-server/rpc/servers.js
Normal file
48
packages/roleypoly-server/rpc/servers.js
Normal file
|
@ -0,0 +1,48 @@
|
|||
// @flow
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import { type Context } from 'koa'
|
||||
import { type Guild } from 'eris'
|
||||
import * as secureAs from './_security'
|
||||
|
||||
export default ($: AppContext) => ({
|
||||
|
||||
rootGetAllServers: secureAs.root($, (ctx: Context) => {
|
||||
return $.discord.client.guilds.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 }))
|
||||
}),
|
||||
|
||||
getServerSlug (ctx: Context, id: string) {
|
||||
const srv = $.discord.client.guilds.get(id)
|
||||
if (srv == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return $.P.serverSlug(srv)
|
||||
},
|
||||
|
||||
getServer: secureAs.member($, (ctx: Context, id: string) => {
|
||||
const { userId } = (ctx.session: { userId: string })
|
||||
|
||||
const srv = $.discord.client.guilds.get(id)
|
||||
if (srv == null) {
|
||||
return { err: 'not_found' }
|
||||
}
|
||||
|
||||
let gm
|
||||
if (srv.members.has(userId)) {
|
||||
gm = $.discord.gm(id, userId)
|
||||
} else if ($.discord.isRoot(userId)) {
|
||||
gm = $.discord.fakeGm({ id: userId })
|
||||
}
|
||||
|
||||
if (gm == null) {
|
||||
return { err: 'not_found' }
|
||||
}
|
||||
|
||||
return $.P.presentableServer(srv, gm)
|
||||
})
|
||||
})
|
16
packages/roleypoly-server/rpc/user.js
Normal file
16
packages/roleypoly-server/rpc/user.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
// @flow
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import { type Context } from 'koa'
|
||||
import * as secureAs from './_security'
|
||||
|
||||
export default ($: AppContext) => ({
|
||||
|
||||
getCurrentUser: secureAs.authed($, async (ctx: Context) => {
|
||||
return await $.discord.getUserPartial(ctx.session.userId)
|
||||
}),
|
||||
|
||||
isRoot: secureAs.root($, () => {
|
||||
return true
|
||||
})
|
||||
|
||||
})
|
12
packages/roleypoly-server/services/Service.js
Normal file
12
packages/roleypoly-server/services/Service.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
// @flow
|
||||
import { Logger } from '../logger'
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
|
||||
export default class Service {
|
||||
ctx: AppContext
|
||||
log: Logger
|
||||
constructor (ctx: AppContext) {
|
||||
this.ctx = ctx
|
||||
this.log = new Logger(this.constructor.name)
|
||||
}
|
||||
}
|
102
packages/roleypoly-server/services/auth.js
Normal file
102
packages/roleypoly-server/services/auth.js
Normal file
|
@ -0,0 +1,102 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import nanoid from 'nanoid'
|
||||
import moniker from 'moniker'
|
||||
import type { AppContext } from '../Roleypoly'
|
||||
import type { Context } from 'koa'
|
||||
// import type { UserPartial } from './discord'
|
||||
// import type { Models } from '../models'
|
||||
|
||||
export type DMChallenge = {
|
||||
userId: string, // snowflake of the user
|
||||
human: string, // humanized string for input elsewhere, adjective adjective noun
|
||||
magic: string, // magic URL, a nanoid
|
||||
issuedAt: Date
|
||||
}
|
||||
|
||||
export type AuthTokens = {
|
||||
access_token: string,
|
||||
refresh_token: string,
|
||||
expires_in: string
|
||||
}
|
||||
|
||||
export default class AuthService extends Service {
|
||||
M: { AuthChallenge: any }
|
||||
monikerGen = moniker.generator([ moniker.adjective, moniker.adjective, moniker.noun ], { glue: ' ' })
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.M = ctx.M
|
||||
}
|
||||
|
||||
async isLoggedIn (ctx: Context, { refresh = false }: { refresh: boolean } = {}) {
|
||||
const { userId, expiresAt, authType } = ctx.session
|
||||
if (userId == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (expiresAt < Date.now()) {
|
||||
if (refresh && authType === 'oauth') {
|
||||
const tokens = await this.ctx.discord.refreshOAuth(ctx.session)
|
||||
this.injectSessionFromOAuth(ctx, tokens, userId)
|
||||
return true
|
||||
}
|
||||
|
||||
ctx.session = null // reset session as well
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async createDMChallenge (userId: string): Promise<DMChallenge> {
|
||||
const out: DMChallenge = {
|
||||
userId,
|
||||
human: this.monikerGen.choose(),
|
||||
magic: nanoid(10),
|
||||
issuedAt: new Date()
|
||||
}
|
||||
|
||||
await this.M.AuthChallenge.build({ ...out, type: 'dm' }).save()
|
||||
this.log.debug('created DM auth challenge', out)
|
||||
return out
|
||||
}
|
||||
|
||||
async fetchDMChallenge (input: { human: string } | { magic: string }): Promise<?DMChallenge> {
|
||||
const challenge: ?DMChallenge = this.M.AuthChallenge.findOne({ where: input })
|
||||
if (challenge == null) {
|
||||
this.log.debug('challenge not found', challenge)
|
||||
return null
|
||||
}
|
||||
|
||||
// if issued more than 1 hour ago, it doesn't matter.
|
||||
if (+challenge.issuedAt + 3.6e5 < new Date()) {
|
||||
this.log.debug('challenge expired', challenge)
|
||||
return null
|
||||
}
|
||||
|
||||
return challenge
|
||||
}
|
||||
|
||||
deleteDMChallenge (input: DMChallenge) {
|
||||
return this.M.AuthChallenge.destroy({ where: { magic: input.magic } })
|
||||
}
|
||||
|
||||
injectSessionFromChallenge (ctx: Context, chall: DMChallenge) {
|
||||
ctx.session = {
|
||||
userId: chall.userId,
|
||||
authType: 'dm',
|
||||
expiresAt: Date.now() + 1000 * 60 * 60 * 24
|
||||
}
|
||||
}
|
||||
|
||||
injectSessionFromOAuth (ctx: Context, tokens: AuthTokens, userId: string) {
|
||||
const { expires_in: expiresIn, access_token: accessToken, refresh_token: refreshToken } = tokens
|
||||
ctx.session = {
|
||||
userId,
|
||||
authType: 'oauth',
|
||||
expiresAt: Date.now() + expiresIn,
|
||||
accessToken,
|
||||
refreshToken
|
||||
}
|
||||
}
|
||||
}
|
351
packages/roleypoly-server/services/discord.js
Normal file
351
packages/roleypoly-server/services/discord.js
Normal file
|
@ -0,0 +1,351 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import type { AppContext } from '../Roleypoly'
|
||||
import Bot from '../bot'
|
||||
import Eris, { type Member, Role, type Guild, type Permission as ErisPermission } from 'eris'
|
||||
import LRU from 'lru-cache'
|
||||
// $FlowFixMe
|
||||
import { OrderedSet } from 'immutable'
|
||||
import superagent from 'superagent'
|
||||
import type { AuthTokens } from './auth'
|
||||
import type { IFetcher } from './discord/types'
|
||||
|
||||
type DiscordServiceConfig = {
|
||||
token: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
rootUsers: Set<string>,
|
||||
isBot: boolean
|
||||
}
|
||||
|
||||
export type Permissions = {
|
||||
canManageRoles: boolean,
|
||||
isAdmin: boolean,
|
||||
faked?: boolean,
|
||||
__faked?: Permissions
|
||||
}
|
||||
|
||||
type CachedRole = {
|
||||
id: string,
|
||||
position: number,
|
||||
color?: number
|
||||
}
|
||||
|
||||
type OAuthRequestData = {
|
||||
grant_type: 'authorization_code',
|
||||
code: string
|
||||
} | {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: string
|
||||
} | {
|
||||
grant_type: 'access_token',
|
||||
token: string
|
||||
}
|
||||
|
||||
export type UserPartial = {
|
||||
id: string,
|
||||
username: string,
|
||||
discriminator: string,
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export type MemberExt = Member & {
|
||||
color?: number,
|
||||
__faked?: true
|
||||
}
|
||||
|
||||
export default class DiscordService extends Service {
|
||||
ctx: AppContext
|
||||
bot: Bot
|
||||
client: Eris
|
||||
|
||||
cfg: DiscordServiceConfig
|
||||
|
||||
// a small cache of role data for checking viability
|
||||
ownRoleCache: LRU<string, CachedRole>
|
||||
topRoleCache: LRU<string, CachedRole>
|
||||
|
||||
oauthCallback: string
|
||||
|
||||
fetcher: IFetcher
|
||||
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.ctx = ctx
|
||||
|
||||
this.cfg = {
|
||||
rootUsers: new Set((process.env.ROOT_USERS || '').split(',')),
|
||||
token: process.env.DISCORD_BOT_TOKEN || '',
|
||||
clientId: process.env.DISCORD_CLIENT_ID || '',
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET || '',
|
||||
isBot: process.env.IS_BOT === 'true'
|
||||
}
|
||||
|
||||
this.oauthCallback = `${ctx.config.appUrl}/api/oauth/callback`
|
||||
|
||||
this.ownRoleCache = new LRU()
|
||||
this.topRoleCache = new LRU()
|
||||
|
||||
if (this.cfg.isBot) {
|
||||
this.client = new Eris(this.cfg.token, {
|
||||
disableEveryone: true,
|
||||
maxShards: 'auto',
|
||||
messageLimit: 10,
|
||||
disableEvents: {
|
||||
CHANNEL_PINS_UPDATE: true,
|
||||
USER_SETTINGS_UPDATE: true,
|
||||
USER_NOTE_UPDATE: true,
|
||||
RELATIONSHIP_ADD: true,
|
||||
RELATIONSHIP_REMOVE: true,
|
||||
GUILD_BAN_ADD: true,
|
||||
GUILD_BAN_REMOVE: true,
|
||||
TYPING_START: true,
|
||||
MESSAGE_UPDATE: true,
|
||||
MESSAGE_DELETE: true,
|
||||
MESSAGE_DELETE_BULK: true,
|
||||
VOICE_STATE_UPDATE: true
|
||||
}
|
||||
})
|
||||
this.bot = new Bot(this)
|
||||
const BotFetcher = require('./discord/botFetcher').default
|
||||
this.fetcher = new BotFetcher(this)
|
||||
} else {
|
||||
this.client = new Eris(`Bot ${this.cfg.token}`, {
|
||||
restMode: true
|
||||
})
|
||||
const RestFetcher = require('./discord/restFetcher').default
|
||||
this.fetcher = new RestFetcher(this)
|
||||
}
|
||||
}
|
||||
|
||||
isRoot (id: string): boolean {
|
||||
return this.cfg.rootUsers.has(id)
|
||||
}
|
||||
|
||||
getRelevantServers (user: string) {
|
||||
return this.client.guilds.filter(guild => guild.members.has(user))
|
||||
}
|
||||
|
||||
async gm (serverId: string, userId: string, { canFake = false }: { canFake: boolean } = {}): Promise<?MemberExt> {
|
||||
const gm: ?Member = await this.fetcher.getMember(serverId, userId)
|
||||
if (gm == null && this.isRoot(userId)) {
|
||||
return this.fakeGm({ id: userId })
|
||||
}
|
||||
|
||||
if (gm == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const out: MemberExt = gm
|
||||
out.color = this.getHighestRole(gm).color
|
||||
return out
|
||||
}
|
||||
|
||||
ownGm (serverId: string) {
|
||||
return this.gm(serverId, this.client.user.id)
|
||||
}
|
||||
|
||||
fakeGm ({ id = '0', nick = '[none]', color = 0 }: $Shape<MemberExt>): $Shape<MemberExt> {
|
||||
return { id, nick, color, __faked: true, roles: [] }
|
||||
}
|
||||
|
||||
getRoles (server: string) {
|
||||
return this.client.guilds.get(server)?.roles
|
||||
}
|
||||
|
||||
async getOwnPermHeight (server: Guild): Promise<number> {
|
||||
if (this.ownRoleCache.has(server)) {
|
||||
const r = this.ownRoleCache.get(server)
|
||||
return r.position
|
||||
}
|
||||
|
||||
const gm = await this.ownGm(server.id)
|
||||
const g = gm?.guild
|
||||
const r: Role = OrderedSet(gm?.roles).map(id => g?.roles.get(id)).minBy(r => r.position)
|
||||
this.ownRoleCache.set(server, {
|
||||
id: r.id,
|
||||
position: r.position
|
||||
})
|
||||
|
||||
return r.position
|
||||
}
|
||||
|
||||
getHighestRole (gm: MemberExt): Role {
|
||||
const trk = `${gm.guild.id}:${gm.id}`
|
||||
if (this.topRoleCache.has(trk)) {
|
||||
const r = gm.guild.roles.get(this.topRoleCache.get(trk).id)
|
||||
if (r != null) {
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
const g = gm.guild
|
||||
const top = OrderedSet(gm.roles).map(id => g.roles.get(id)).minBy(r => r.position)
|
||||
this.topRoleCache.set(trk, {
|
||||
id: top.id,
|
||||
position: top.position,
|
||||
color: top.color
|
||||
})
|
||||
|
||||
return top
|
||||
}
|
||||
|
||||
calcPerms (permable: Role | Member): Permissions {
|
||||
const p: ErisPermission = (permable instanceof Role) ? permable.permissions : permable.permission
|
||||
return {
|
||||
isAdmin: p.has('administrator'),
|
||||
canManageRoles: p.has('manageRoles') || p.has('administrator')
|
||||
}
|
||||
}
|
||||
|
||||
getPermissions (gm: Member): Permissions {
|
||||
const real = this.calcPerms(gm)
|
||||
|
||||
if (this.isRoot(gm.id)) {
|
||||
return {
|
||||
isAdmin: true,
|
||||
canManageRoles: true,
|
||||
faked: true,
|
||||
__faked: real
|
||||
}
|
||||
}
|
||||
|
||||
return real
|
||||
}
|
||||
|
||||
async safeRole (server: string, role: string) {
|
||||
const r = this.getRoles(server)?.get(role)
|
||||
if (r == null) {
|
||||
throw new Error(`safeRole can't find ${role} in ${server}`)
|
||||
}
|
||||
|
||||
return (await this.roleIsEditable(r)) && !this.calcPerms(r).canManageRoles
|
||||
}
|
||||
|
||||
async roleIsEditable (role: Role): Promise<boolean> {
|
||||
// role will be LOWER than my own
|
||||
const ownPh = await this.getOwnPermHeight(role.guild)
|
||||
return ownPh > role.position
|
||||
}
|
||||
|
||||
async oauthRequest (path: string, data: OAuthRequestData) {
|
||||
const url = `https://discordapp.com/api/oauth2/${path}`
|
||||
try {
|
||||
const rsp = await superagent.post(url)
|
||||
.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||
.set('User-Agent', 'DiscordBot (https://roleypoly.com, 2.x.x) OAuthHandler/1.0')
|
||||
.send({
|
||||
client_id: this.cfg.clientId,
|
||||
client_secret: this.cfg.clientSecret,
|
||||
redirect_uri: this.oauthCallback,
|
||||
...data
|
||||
})
|
||||
|
||||
return (rsp.body: AuthTokens)
|
||||
} catch (e) {
|
||||
this.log.error('oauthRequest failed', { e, path })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
initializeOAuth (code: string) {
|
||||
return this.oauthRequest('token', {
|
||||
grant_type: 'authorization_code',
|
||||
code
|
||||
})
|
||||
}
|
||||
|
||||
refreshOAuth ({ refreshToken }: { refreshToken: string }) {
|
||||
return this.oauthRequest('token', {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
}
|
||||
|
||||
revokeOAuth ({ accessToken }: { accessToken: string }) {
|
||||
return this.oauthRequest('token/revoke', {
|
||||
grant_type: 'access_token',
|
||||
token: accessToken
|
||||
})
|
||||
}
|
||||
|
||||
async getUserPartial (userId: string): Promise<?UserPartial> {
|
||||
const u = await this.fetcher.getUser(userId)
|
||||
if (u == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
username: u.username,
|
||||
discriminator: u.discriminator,
|
||||
avatar: u.avatarURL,
|
||||
id: u.id
|
||||
}
|
||||
}
|
||||
|
||||
async getUserFromToken (authToken: string): Promise<UserPartial> {
|
||||
const url = 'https://discordapp.com/api/v6/users/@me'
|
||||
try {
|
||||
const rsp = await superagent.get(url)
|
||||
.set('User-Agent', 'DiscordBot (https://roleypoly.com, 2.x.x) OAuthHandler/1.0')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
|
||||
return rsp.body
|
||||
} catch (e) {
|
||||
this.log.error('getUser error', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
getAuthUrl (state: string): string {
|
||||
return `https://discordapp.com/oauth2/authorize?client_id=${this.cfg.clientId}&redirect_uri=${this.oauthCallback}&response_type=code&scope=identify&state=${state}`
|
||||
}
|
||||
|
||||
// returns the bot join url with MANAGE_ROLES permission
|
||||
// MANAGE_ROLES is the only permission we really need.
|
||||
getBotJoinUrl (): string {
|
||||
return `https://discordapp.com/oauth2/authorize?client_id=${this.cfg.clientId}&scope=bot&permissions=268435456`
|
||||
}
|
||||
|
||||
async issueChallenge (author: string) {
|
||||
// Create a challenge
|
||||
const chall = await this.ctx.auth.createDMChallenge(author)
|
||||
|
||||
const randomLines = [
|
||||
'🐄 A yellow cow is only as bright as it lets itself be. ✨',
|
||||
'‼ **Did you know?** On this day, at least one second ago, you were right here!',
|
||||
'<:AkkoC8:437428070849314816> *Reticulating splines...*',
|
||||
'Also, you look great today <:YumekoWink:439519270376964107>',
|
||||
'btw, ur bright like a <:diamond:544665968631087125>',
|
||||
`🌈 psst! pssssst! I'm an expensive bot, would you please spare some change? <https://ko-fi.com/roleypoly>`,
|
||||
'📣 have suggestions? wanna help out? join my discord! <https://discord.gg/PWQUVsd>\n*(we\'re nice people, i swear!)*',
|
||||
`🤖 this bot is at least ${Math.random() * 100}% LIT 🔥`,
|
||||
'💖 wanna contribute to these witty lines? <https://discord.gg/PWQUVsd> suggest them on our discord!',
|
||||
'🛠 I am completely open source, check me out!~ <https://github.com/kayteh/roleypoly>'
|
||||
]
|
||||
|
||||
return ([
|
||||
'**Hey there!** <a:StockKyaa:435353158462603266>',
|
||||
'',
|
||||
`Use this secret code: **${chall.human}**`,
|
||||
`Or, click here: <${this.ctx.config.appUrl}/magic/${chall.magic}>`,
|
||||
'',
|
||||
'This code will self-destruct in 1 hour.',
|
||||
'---',
|
||||
randomLines[Math.floor(Math.random() * randomLines.length)]
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
async canManageRoles (server: string, user: string): Promise<boolean> {
|
||||
const gm = await this.gm(server, user)
|
||||
if (gm == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.getPermissions(gm).canManageRoles
|
||||
}
|
||||
|
||||
isMember (server: string, user: string): boolean {
|
||||
return this.gm(server, user) != null
|
||||
}
|
||||
}
|
346
packages/roleypoly-server/services/discord.old.js
Normal file
346
packages/roleypoly-server/services/discord.old.js
Normal file
|
@ -0,0 +1,346 @@
|
|||
/*
|
||||
import Service from './Service'
|
||||
import superagent from 'superagent'
|
||||
// import invariant from 'invariant'
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import Eris from 'eris'
|
||||
import Bot from '../bot'
|
||||
import type { AuthTokens } from './auth'
|
||||
export type UserPartial = {
|
||||
id: string,
|
||||
username: string,
|
||||
discriminator: string,
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export type Permissions = {
|
||||
isAdmin: boolean,
|
||||
canManageRoles: boolean
|
||||
}
|
||||
|
||||
// type ChatCommandHandler = (message: Message, matches: string[], reply: (text: string) => Promise<Message>) => Promise<void>|void
|
||||
// type ChatCommand = {
|
||||
// regex: RegExp,
|
||||
// handler: ChatCommandHandler
|
||||
// }
|
||||
|
||||
class DiscordService extends Service {
|
||||
botToken: string = process.env.DISCORD_BOT_TOKEN || ''
|
||||
clientId: string = process.env.DISCORD_CLIENT_ID || ''
|
||||
clientSecret: string = process.env.DISCORD_CLIENT_SECRET || ''
|
||||
oauthCallback: string = process.env.OAUTH_AUTH_CALLBACK || ''
|
||||
isBot: boolean = process.env.IS_BOT === 'true'
|
||||
|
||||
appUrl: string
|
||||
botCallback: string
|
||||
rootUsers: Set<string>
|
||||
client: Eris
|
||||
cmds: ChatCommand[]
|
||||
bot: Bot
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.appUrl = ctx.config.appUrl
|
||||
|
||||
this.oauthCallback = `${this.appUrl}/api/oauth/callback`
|
||||
this.botCallback = `${this.appUrl}/api/oauth/bot/callback`
|
||||
this.rootUsers = new Set((process.env.ROOT_USERS || '').split(','))
|
||||
this.bot = new Bot(this)
|
||||
}
|
||||
|
||||
ownGm (server: string) {
|
||||
return this.gm(server, this.client.user.id)
|
||||
}
|
||||
|
||||
fakeGm ({ id = '0', nickname = '[none]', displayHexColor = '#ffffff' }: $Shape<{ id: string, nickname: string, displayHexColor: string }>) { // eslint-disable-line no-undef
|
||||
return { id, nickname, displayHexColor, __faked: true, roles: { has () { return false } } }
|
||||
}
|
||||
|
||||
isRoot (id: string): boolean {
|
||||
return this.rootUsers.has(id)
|
||||
}
|
||||
|
||||
getRelevantServers (userId: string): Collection<string, Guild> {
|
||||
return this.client.guilds.filter((g) => g.members.has(userId))
|
||||
}
|
||||
|
||||
gm (serverId: string, userId: string): ?GuildMember {
|
||||
const s = this.client.guilds.get(serverId)
|
||||
if (s == null) {
|
||||
return null
|
||||
}
|
||||
return s.members.get(userId)
|
||||
}
|
||||
|
||||
getRoles (server: string) {
|
||||
const s = this.client.guilds.get(server)
|
||||
if (s == null) {
|
||||
return null
|
||||
}
|
||||
return s.roles
|
||||
}
|
||||
|
||||
getPermissions (gm: GuildMember): Permissions {
|
||||
if (this.isRoot(gm.id)) {
|
||||
return {
|
||||
isAdmin: true,
|
||||
canManageRoles: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isAdmin: gm.permissions.has('ADMINISTRATOR'),
|
||||
canManageRoles: gm.permissions.has('MANAGE_ROLES', true)
|
||||
}
|
||||
}
|
||||
|
||||
safeRole (server: string, role: string) {
|
||||
const rl = this.getRoles(server)
|
||||
if (rl == null) {
|
||||
throw new Error(`safeRole can't see ${server}`)
|
||||
}
|
||||
const r: ?Role = rl.get(role)
|
||||
if (r == null) {
|
||||
throw new Error(`safeRole can't find ${role} in ${server}`)
|
||||
}
|
||||
|
||||
return r.editable && !r.hasPermission('MANAGE_ROLES', false, true)
|
||||
}
|
||||
|
||||
// oauth step 2 flow, grab the auth token via code
|
||||
async getAuthToken (code: string): Promise<AuthTokens> {
|
||||
const url = 'https://discordapp.com/api/oauth2/token'
|
||||
try {
|
||||
const rsp =
|
||||
await superagent
|
||||
.post(url)
|
||||
.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||
.send({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: this.oauthCallback
|
||||
})
|
||||
|
||||
return rsp.body
|
||||
} catch (e) {
|
||||
this.log.error('getAuthToken failed', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async getUserFromToken (authToken?: string): Promise<UserPartial> {
|
||||
const url = 'https://discordapp.com/api/v6/users/@me'
|
||||
try {
|
||||
if (authToken == null || authToken === '') {
|
||||
throw new Error('not logged in')
|
||||
}
|
||||
|
||||
const rsp =
|
||||
await superagent
|
||||
.get(url)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
return rsp.body
|
||||
} catch (e) {
|
||||
this.log.error('getUser error', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
getUserPartial (userId: string): ?UserPartial {
|
||||
const U = this.client.users.get(userId)
|
||||
if (U == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
username: U.username,
|
||||
discriminator: U.discriminator,
|
||||
avatar: U.displayAvatarURL,
|
||||
id: U.id
|
||||
}
|
||||
}
|
||||
|
||||
async refreshOAuth ({ refreshToken }: { refreshToken: string }): Promise<AuthTokens> {
|
||||
const url = 'https://discordapp.com/api/oauth2/token'
|
||||
try {
|
||||
const rsp =
|
||||
await superagent
|
||||
.post(url)
|
||||
.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||
.send({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
redirect_uri: this.oauthCallback
|
||||
})
|
||||
|
||||
return rsp.body
|
||||
} catch (e) {
|
||||
this.log.error('refreshOAuth failed', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async revokeOAuth ({ accessToken }: { accessToken: string }) {
|
||||
const url = 'https://discordapp.com/api/oauth2/token/revoke'
|
||||
try {
|
||||
const rsp =
|
||||
await superagent
|
||||
.post(url)
|
||||
.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||
.send({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
grant_type: 'access_token',
|
||||
token: accessToken,
|
||||
redirect_uri: this.oauthCallback
|
||||
})
|
||||
|
||||
return rsp.body
|
||||
} catch (e) {
|
||||
this.log.error('revokeOAuth failed', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// returns oauth authorize url with IDENTIFY permission
|
||||
// we only need IDENTIFY because we only use it for matching IDs from the bot
|
||||
getAuthUrl (state: string): string {
|
||||
return `https://discordapp.com/oauth2/authorize?client_id=${this.clientId}&redirect_uri=${this.oauthCallback}&response_type=code&scope=identify&state=${state}`
|
||||
}
|
||||
|
||||
// returns the bot join url with MANAGE_ROLES permission
|
||||
// MANAGE_ROLES is the only permission we really need.
|
||||
getBotJoinUrl (): string {
|
||||
return `https://discordapp.com/oauth2/authorize?client_id=${this.clientId}&scope=bot&permissions=268435456`
|
||||
}
|
||||
|
||||
mentionResponse (message: Message) {
|
||||
message.channel.send(`🔰 Assign your roles here! ${this.appUrl}/s/${message.guild.id}`, { disableEveryone: true })
|
||||
}
|
||||
|
||||
_cmds () {
|
||||
const cmds = [
|
||||
{
|
||||
regex: /say (.*)/,
|
||||
handler (message, matches, r) {
|
||||
r(matches[0])
|
||||
}
|
||||
},
|
||||
{
|
||||
regex: /set username (.*)/,
|
||||
async handler (message, matches) {
|
||||
const { username } = this.client.user
|
||||
await this.client.user.setUsername(matches[0])
|
||||
message.channel.send(`Username changed from ${username} to ${matches[0]}`)
|
||||
}
|
||||
},
|
||||
{
|
||||
regex: /stats/,
|
||||
async handler (message, matches) {
|
||||
const t = [
|
||||
`**Stats** 📈`,
|
||||
'',
|
||||
`👩❤️👩 **Users Served:** ${this.client.guilds.reduce((acc, g) => acc + g.memberCount, 0)}`,
|
||||
`🔰 **Servers:** ${this.client.guilds.size}`,
|
||||
`💮 **Roles Seen:** ${this.client.guilds.reduce((acc, g) => acc + g.roles.size, 0)}`
|
||||
]
|
||||
message.channel.send(t.join('\n'))
|
||||
}
|
||||
}
|
||||
]
|
||||
// prefix regex with ^ for ease of code
|
||||
.map(({ regex, ...rest }) => ({ regex: new RegExp(`^${regex.source}`, regex.flags), ...rest }))
|
||||
|
||||
this.cmds = cmds
|
||||
return cmds
|
||||
}
|
||||
|
||||
async handleCommand (message: Message) {
|
||||
const cmd = message.content.replace(`<@${this.client.user.id}> `, '')
|
||||
this.log.debug(`got command from ${message.author.username}`, cmd)
|
||||
for (let { regex, handler } of this.cmds) {
|
||||
const match = regex.exec(cmd)
|
||||
if (match !== null) {
|
||||
this.log.debug('command accepted', { cmd, match })
|
||||
try {
|
||||
await handler.call(this, message, match.slice(1))
|
||||
return
|
||||
} catch (e) {
|
||||
this.log.error('command errored', { e, cmd, message })
|
||||
message.channel.send(`❌ **An error occured.** ${e}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nothing matched?
|
||||
this.mentionResponse(message)
|
||||
}
|
||||
|
||||
async issueChallenge (author: string) {
|
||||
// Create a challenge
|
||||
const chall = await this.ctx.auth.createDMChallenge(author)
|
||||
|
||||
const randomLines = [
|
||||
'🐄 A yellow cow is only as bright as it lets itself be. ✨',
|
||||
'‼ **Did you know?** On this day, at least one second ago, you were right here!',
|
||||
'<:AkkoC8:437428070849314816> *Reticulating splines...*',
|
||||
'Also, you look great today <:YumekoWink:439519270376964107>',
|
||||
'btw, ur bright like a <:diamond:544665968631087125>',
|
||||
`🌈 psst! pssssst! I'm an expensive bot, would you please spare some change? <https://ko-fi.com/roleypoly>`,
|
||||
'📣 have suggestions? wanna help out? join my discord! <https://discord.gg/PWQUVsd>\n*(we\'re nice people, i swear!)*',
|
||||
`🤖 this bot is at least ${Math.random() * 100}% LIT 🔥`,
|
||||
'💖 wanna contribute to these witty lines? <https://discord.gg/PWQUVsd> suggest them on our discord!',
|
||||
'🛠 I am completely open source, check me out!~ <https://github.com/kayteh/roleypoly>'
|
||||
]
|
||||
|
||||
return ([
|
||||
'**Hey there!** <a:StockKyaa:435353158462603266>',
|
||||
'',
|
||||
`Use this secret code: **${chall.human}**`,
|
||||
`Or, click here: <${this.ctx.config.appUrl}/magic/${chall.magic}>`,
|
||||
'',
|
||||
'This code will self-destruct in 1 hour.',
|
||||
'---',
|
||||
randomLines[Math.floor(Math.random() * randomLines.length)]
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
handleDM (message: Message) {
|
||||
switch (message.content.toLowerCase()) {
|
||||
case 'login':
|
||||
case 'auth':
|
||||
case 'log in':
|
||||
this.issueChallenge(message)
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage (message: Message) {
|
||||
if (message.author.bot) { // drop bot messages
|
||||
return
|
||||
}
|
||||
|
||||
if (message.channel.type === 'dm') {
|
||||
// handle dm
|
||||
return this.handleDM(message)
|
||||
}
|
||||
|
||||
if (message.mentions.users.has(this.client.user.id)) {
|
||||
if (this.rootUsers.has(message.author.id)) {
|
||||
this.handleCommand(message)
|
||||
} else {
|
||||
this.mentionResponse(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleJoin (guild: Guild) {
|
||||
await this.ctx.server.ensure(guild)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DiscordService
|
||||
*/
|
22
packages/roleypoly-server/services/discord/botFetcher.js
Normal file
22
packages/roleypoly-server/services/discord/botFetcher.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
// @flow
|
||||
import type { IFetcher } from './types'
|
||||
import type DiscordSvc from '../discord'
|
||||
import type ErisClient, { User, Member, Guild } from 'eris'
|
||||
|
||||
export default class BotFetcher implements IFetcher {
|
||||
ctx: DiscordSvc
|
||||
client: ErisClient
|
||||
constructor (ctx: DiscordSvc) {
|
||||
this.ctx = ctx
|
||||
this.client = ctx.client
|
||||
}
|
||||
|
||||
getUser = async (id: string): Promise<?User> =>
|
||||
this.client.users.get(id)
|
||||
|
||||
getMember = async (server: string, user: string): Promise<?Member> =>
|
||||
this.client.guilds.get(server)?.members.get(user)
|
||||
|
||||
getGuild = async (server: string): Promise<?Guild> =>
|
||||
this.client.guilds.get(server)
|
||||
}
|
36
packages/roleypoly-server/services/discord/restFetcher.js
Normal file
36
packages/roleypoly-server/services/discord/restFetcher.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
// @flow
|
||||
import type { IFetcher } from './types'
|
||||
import type DiscordSvc from '../discord'
|
||||
import type ErisClient, { User, Member, Guild } from 'eris'
|
||||
|
||||
export default class BotFetcher implements IFetcher {
|
||||
ctx: DiscordSvc
|
||||
client: ErisClient
|
||||
constructor (ctx: DiscordSvc) {
|
||||
this.ctx = ctx
|
||||
this.client = ctx.client
|
||||
}
|
||||
|
||||
getUser = async (id: string): Promise<?User> => {
|
||||
try {
|
||||
return await this.client.getRESTUser(id)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
getMember = async (server: string, user: string): Promise<?Member> => {
|
||||
try {
|
||||
return await this.client.getRESTGuildMember(server, user)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getGuild = async (server: string): Promise<?Guild> => {
|
||||
try {
|
||||
return await this.client.getRESTGuild(server)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
14
packages/roleypoly-server/services/discord/types.js
Normal file
14
packages/roleypoly-server/services/discord/types.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
// @flow
|
||||
import type {
|
||||
User,
|
||||
Member,
|
||||
Guild
|
||||
} from 'eris'
|
||||
|
||||
export interface IFetcher {
|
||||
getUser: (id: string) => Promise<?User>;
|
||||
|
||||
getGuild: (id: string) => Promise<?Guild>;
|
||||
|
||||
getMember: (server: string, user: string) => Promise<?Member>;
|
||||
}
|
107
packages/roleypoly-server/services/presentation.js
Normal file
107
packages/roleypoly-server/services/presentation.js
Normal file
|
@ -0,0 +1,107 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import LRU from 'lru-cache'
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import { type Models } from '../models'
|
||||
import { type ServerModel } from '../models/Server'
|
||||
import type DiscordService, { Permissions } from './discord'
|
||||
import {
|
||||
type Guild,
|
||||
type Member,
|
||||
type Collection
|
||||
} from 'eris'
|
||||
import areduce from '../util/areduce'
|
||||
|
||||
export type ServerSlug = {
|
||||
id: string,
|
||||
name: string,
|
||||
ownerID: string,
|
||||
icon: string
|
||||
}
|
||||
|
||||
export type PresentableRole = {
|
||||
id: string,
|
||||
color: number,
|
||||
name: string,
|
||||
position: number,
|
||||
safe: boolean
|
||||
}
|
||||
|
||||
export type PresentableServer = ServerModel & {
|
||||
id: string,
|
||||
gm?: {
|
||||
color: number | string,
|
||||
nickname: string,
|
||||
roles: string[]
|
||||
},
|
||||
server: ServerSlug,
|
||||
roles: ?PresentableRole[],
|
||||
perms: Permissions
|
||||
}
|
||||
|
||||
class PresentationService extends Service {
|
||||
cache: LRU
|
||||
M: Models
|
||||
discord: DiscordService
|
||||
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.M = ctx.M
|
||||
this.discord = ctx.discord
|
||||
|
||||
this.cache = new LRU({ max: 500, maxAge: 100 * 60 * 5 })
|
||||
}
|
||||
|
||||
serverSlug (server: Guild): ServerSlug {
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
ownerID: server.ownerID,
|
||||
icon: server.icon
|
||||
}
|
||||
}
|
||||
|
||||
presentableServers (collection: Collection<string, Guild>, userId: string) {
|
||||
return areduce(collection.array(), async (acc, server) => {
|
||||
const gm = server.members.get(userId)
|
||||
if (gm == null) {
|
||||
throw new Error(`somehow this guildmember ${userId} of ${server.id} didn't exist.`)
|
||||
}
|
||||
|
||||
acc.push(await this.presentableServer(server, gm, { incRoles: false }))
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
async presentableServer (server: Guild, gm: Member, { incRoles = true }: { incRoles: boolean } = {}): Promise<PresentableServer> {
|
||||
const sd = await this.ctx.server.get(server.id)
|
||||
|
||||
return {
|
||||
id: server.id,
|
||||
gm: {
|
||||
nickname: gm.nickname || gm.user.username,
|
||||
color: gm.displayHexColor,
|
||||
roles: gm.roles.keyArray()
|
||||
},
|
||||
server: this.serverSlug(server),
|
||||
roles: (incRoles) ? (await this.rolesByServer(server, sd)).map(r => ({ ...r, selected: gm.roles.has(r.id) })) : [],
|
||||
message: sd.message,
|
||||
categories: sd.categories,
|
||||
perms: this.discord.getPermissions(gm)
|
||||
}
|
||||
}
|
||||
|
||||
async rolesByServer (server: Guild, sd: ServerModel): Promise<PresentableRole[]> {
|
||||
return server.roles
|
||||
.filter(r => r.id !== server.id) // get rid of @everyone
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
color: r.color,
|
||||
name: r.name,
|
||||
position: r.position,
|
||||
safe: this.discord.safeRole(server.id, r.id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PresentationService
|
75
packages/roleypoly-server/services/server.js
Normal file
75
packages/roleypoly-server/services/server.js
Normal file
|
@ -0,0 +1,75 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
import type PresentationService from './presentation'
|
||||
import {
|
||||
type Guild
|
||||
} from 'eris'
|
||||
import { type ServerModel, type Category } from '../models/Server'
|
||||
|
||||
export default class ServerService extends Service {
|
||||
Server: any // Model<ServerModel> but flowtype is bugged
|
||||
P: PresentationService
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.Server = ctx.M.Server
|
||||
this.P = ctx.P
|
||||
}
|
||||
|
||||
async ensure (server: Guild) {
|
||||
let srv
|
||||
try {
|
||||
srv = await this.get(server.id)
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
|
||||
if (srv == null) {
|
||||
return this.create({
|
||||
id: server.id,
|
||||
message: '',
|
||||
categories: {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
create ({ id, message, categories }: ServerModel) {
|
||||
const srv = this.Server.build({ id, message, categories })
|
||||
|
||||
return srv.save()
|
||||
}
|
||||
|
||||
async update (id: string, newData: $Shape<ServerModel>) { // eslint-disable-line no-undef
|
||||
const srv = await this.get(id, false)
|
||||
|
||||
return srv.update(newData)
|
||||
}
|
||||
|
||||
async get (id: string, plain: boolean = true) {
|
||||
const s = await this.Server.findOne({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
})
|
||||
|
||||
if (!plain) {
|
||||
return s
|
||||
}
|
||||
|
||||
return s.get({ plain: true })
|
||||
}
|
||||
|
||||
async getAllowedRoles (id: string) {
|
||||
const server: ServerModel = await this.get(id)
|
||||
const categories: Category[] = (Object.values(server.categories): any)
|
||||
const allowed: string[] = []
|
||||
|
||||
for (const c of categories) {
|
||||
if (c.hidden !== true) {
|
||||
allowed.concat(c.roles)
|
||||
}
|
||||
}
|
||||
|
||||
return allowed
|
||||
}
|
||||
}
|
49
packages/roleypoly-server/services/sessions.js
Normal file
49
packages/roleypoly-server/services/sessions.js
Normal file
|
@ -0,0 +1,49 @@
|
|||
// @flow
|
||||
import Service from './Service'
|
||||
import { type AppContext } from '../Roleypoly'
|
||||
|
||||
type SessionData = {
|
||||
rolling: boolean,
|
||||
maxAge: number,
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
export default class SessionsService extends Service {
|
||||
Session: any
|
||||
constructor (ctx: AppContext) {
|
||||
super(ctx)
|
||||
this.Session = ctx.M.Session
|
||||
}
|
||||
|
||||
async get (id: string, { rolling }: SessionData) {
|
||||
const user = await this.Session.findOne({ where: { id } })
|
||||
|
||||
if (user === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return user.data
|
||||
}
|
||||
|
||||
async set (id: string, data: any, { maxAge, rolling, changed }: SessionData) {
|
||||
let session = await this.Session.findOne({ where: { id } })
|
||||
if (session === null) {
|
||||
session = this.Session.build({ id })
|
||||
}
|
||||
|
||||
console.log(maxAge)
|
||||
|
||||
session.data = data
|
||||
session.maxAge = maxAge
|
||||
|
||||
return session.save()
|
||||
}
|
||||
|
||||
async destroy (id: string) {
|
||||
const sess = await this.Session.findOne({ where: { id } })
|
||||
|
||||
if (sess != null) {
|
||||
return sess.destroy()
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue