lerna: split up bulk of packages

This commit is contained in:
41666 2019-04-02 23:10:45 -05:00
parent cb0b1d2410
commit 47a2e5694e
No known key found for this signature in database
GPG key ID: BC51D07640DC10AF
90 changed files with 0 additions and 0 deletions

View 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

View 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('/')
})
}

View 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)
}
}
}

View 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 }
})
}

View 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
// })
}

View 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'))
}

View 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
}
}

View 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)
})

View 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)
}

View 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'] } ]
})
}

View 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
}
})
}

View 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
})
}

View 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
}

View 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
}

View 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

View 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
}

View 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
}
})

View 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'
// }
}
}

View 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' } ]
)
})

View 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)
})
})

View 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
})
})

View 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)
}
}

View 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
}
}
}

View 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
}
}

View 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
*/

View 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)
}

View 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
}
}
}

View 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>;
}

View 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

View 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
}
}

View 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()
}
}
}

View file

@ -0,0 +1,9 @@
{
"presets": [
"next/babel", "@babel/preset-flow"
],
"plugins": [
[ "styled-components", { "ssr": true } ],
"@babel/plugin-proposal-optional-chaining"
]
}

19
packages/roleypoly-ui/.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
/dist
/.next
# misc
.DS_Store
.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View file

@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DiscordButton /> renders correctly 1`] = `
<discord-button__Button>
<discord-button__ButtonIcon
src="/static/discord-logo.svg"
/>
 
Hello!
</discord-button__Button>
`;

View file

@ -0,0 +1,91 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DiscordGuildPic /> falls-back to a default when things go bad. 1`] = `
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
background-color: var(--fallback-color);
}
<DiscordGuildPic
icon="aaa"
id="0000"
name="Mock"
>
<discord-guild-pic__Fallback
serverName="Mock"
style={
Object {
"--fallback-color": "hsl(77,50%,50%)",
}
}
>
<StyledComponent
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "discord-guild-pic__Fallback-d55lwu-0",
"isStatic": true,
"lastClassName": "c0",
"rules": Array [
"display:flex;justify-content:center;align-items:center;background-color:var(--fallback-color);",
],
},
"displayName": "discord-guild-pic__Fallback",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "discord-guild-pic__Fallback-d55lwu-0",
"target": "div",
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
serverName="Mock"
style={
Object {
"--fallback-color": "hsl(77,50%,50%)",
}
}
>
<div
className="c0"
style={
Object {
"--fallback-color": "hsl(77,50%,50%)",
}
}
>
M
</div>
</StyledComponent>
</discord-guild-pic__Fallback>
</DiscordGuildPic>
`;
exports[`<DiscordGuildPic /> renders a snapshot 1`] = `
<DiscordGuildPic
icon="aaa"
id="0000"
name="Mock"
>
<img
onError={[Function]}
onLoad={[Function]}
src="https://cdn.discordapp.com/icons/0000/aaa.png"
/>
</DiscordGuildPic>
`;

View file

@ -0,0 +1,14 @@
/* eslint-env jest */
import * as React from 'react'
// import renderer from 'react-test-renderer'
import { shallow } from 'enzyme'
import DiscordButton from '../discord-button'
import 'jest-styled-components'
describe('<DiscordButton />', () => {
it('renders correctly', () => {
const button = shallow(<DiscordButton>Hello!</DiscordButton>)
expect(button).toMatchSnapshot()
expect(button.text().trim()).toEqual('Hello!')
})
})

View file

@ -0,0 +1,33 @@
/* eslint-env jest */
import * as React from 'react'
// import renderer from 'react-test-renderer'
import { shallow, mount } from 'enzyme'
import DiscordGuildPic from '../discord-guild-pic'
import 'jest-styled-components'
describe('<DiscordGuildPic />', () => {
const mockServer = {
id: '0000',
icon: 'aaa',
name: 'Mock'
}
it('renders a snapshot', () => {
const pic = mount(<DiscordGuildPic {...mockServer} />)
expect(pic).toMatchSnapshot()
})
it('renders a well-formatted guild pic correctly', () => {
const pic = shallow(<DiscordGuildPic {...mockServer} />)
const expectedSrc = `https://cdn.discordapp.com/icons/${mockServer.id}/${mockServer.icon}.png`
expect(pic.find('img').prop('src')).toEqual(expectedSrc)
})
it('falls-back to a default when things go bad.', () => {
const pic = mount(<DiscordGuildPic {...mockServer} />)
pic.find('img').simulate('error')
expect(pic).toMatchSnapshot()
expect(pic.text()).toEqual(mockServer.name[0])
})
})

View file

@ -0,0 +1,15 @@
// @flow
import * as React from 'react'
import styled from 'styled-components'
import Role from '../role/demo'
import demoRoles from '../../config/demo'
const DemoWrapper = styled.div`
justify-content: center;
display: flex;
flex-wrap: wrap;
`
export default () => <DemoWrapper>
{ demoRoles.map((v, i) => <Role key={i} role={v} />) }
</DemoWrapper>

View file

@ -0,0 +1,85 @@
import * as React from 'react'
import moment from 'moment'
import Typist from 'react-typist'
import styled from 'styled-components'
import MediaQuery from '../../kit/media'
import demoRoles from '../../config/demo'
const Outer = styled.div`
background-color: var(--dark-but-not-black);
padding: 10px;
text-align: left;
color: var(--c-white);
`
const Chat = styled.div`
padding: 10px 0;
font-size: 0.8em;
${() => MediaQuery({ sm: 'font-size: 1em;' })}
& span {
display: inline-block;
margin-left: 5px;
}
`
const TextArea = styled.div`
background-color: hsla(218,5%,47%,.3);
border-radius: 5px;
padding: 10px;
font-size: 0.8em;
${() => MediaQuery({ sm: 'font-size: 1em;' })}
& .Typist .Cursor {
display: inline-block;
color: transparent;
border-left: 1px solid var(--c-white);
user-select: none;
&--blinking {
opacity: 1;
animation: blink 2s ease-in-out infinite;
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
}
}
`
const Timestamp = styled.span`
font-size: 0.7em;
color: hsla(0,0%,100%,.2);
`
const Username = styled.span`
font-weight: bold;
`
const Typing = () => <Outer>
<Chat>
<Timestamp className='timestamp'>{moment().format('LT')}</Timestamp>
<Username className='username'>okano岡野</Username>
<span className='text'>Hey, I want some roles!</span>
</Chat>
<TextArea>
<Typist cursor={{ blink: true }}>
{ demoRoles.map(({ name }) => [
<span>.iam {name}</span>,
<Typist.Backspace count={30} delay={1500} />
]) }
<span>i have too many roles.</span>
</Typist>
</TextArea>
</Outer>
export default Typing

View file

@ -0,0 +1,62 @@
// @flow
import * as React from 'react'
import styled from 'styled-components'
export type ButtonProps = {
children: React.Node
}
const Button = styled.a`
background-color: var(--c-discord);
color: var(--c-white);
padding: 0.4em 1em;
border-radius: 3px;
border: 1px solid rgba(0,0,0,0.25);
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
transform: translateY(0px);
transition: all 0.3s ease-in-out;
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
transition: all 0.35s ease-in-out;
background-color: hsla(0,0%,100%,0.1);
pointer-events: none;
opacity: 0;
z-index: 2;
}
&:hover {
box-shadow: 0 1px 2px rgba(0,0,0,0.75);
transform: translateY(-1px);
&::after {
opacity: 1;
}
}
&:active {
transform: translateY(0px);
box-shadow: none;
}
`
const ButtonIcon = styled.img`
height: 1.5em;
`
const DiscordButton = ({ children, ...props }: ButtonProps) => (
<Button {...props}>
<ButtonIcon src='/static/discord-logo.svg' />&nbsp;
{children}
</Button>
)
export default DiscordButton

View file

@ -0,0 +1,61 @@
// @flow
// export default ({ id, icon, ...rest }) => <img src={`https://cdn.discordapp.com/icons/${id}/${icon}.png`} {...rest} />
import * as React from 'react'
import styled from 'styled-components'
export type GuildPicProps = {
id: string,
icon: string,
name: string
}
export type GuildPicState = {
src: ?string,
ok: boolean
}
const Fallback = styled.div`
display: flex;
justify-content: center;
align-items: center;
background-color: var(--fallback-color);
`
export default class DiscordGuildPic extends React.Component<GuildPicProps, GuildPicState> {
state = {
src: this.src,
ok: false
}
get src () {
return `https://cdn.discordapp.com/icons/${this.props.id}/${this.props.icon}.png`
}
renderFallback () {
const { name, id, icon, ...rest } = this.props
return <Fallback serverName={name} style={{ '--fallback-color': `hsl(${(name.codePointAt(0) % 360)},50%,50%)` }} {...rest}>{name[0]}</Fallback>
}
onError = () => {
// console.log('onError')
this.setState({
src: null
})
}
onLoad = () => {
this.setState({
ok: true
})
}
renderImg () {
const { name, id, icon, ...rest } = this.props
return <img src={this.state.src} onError={this.onError} onLoad={this.onLoad} {...rest} />
}
render () {
return (this.state.src === null) ? this.renderFallback() : this.renderImg()
}
}

View file

@ -0,0 +1,91 @@
// @flow
// import * as React from 'react'
import { createGlobalStyle } from 'styled-components'
export const colors = {
white: '#efefef',
c9: '#EBD6D4',
c7: '#ab9b9a',
c5: '#756867',
c3: '#5d5352',
c1: '#453e3d',
dark: '#332d2d',
green: '#46b646',
red: '#e95353',
discord: '#7289da'
}
const getColors = () => {
return Object.keys(colors).map(key => {
const nk = key.replace(/c([0-9])/, '$1')
return `--c-${nk}: ${colors[key]};`
}).join(' \n')
}
export default createGlobalStyle`
body {
margin: 0;
padding: 0;
font-family: "source-han-sans-japanese", "Source Sans Pro", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" !important;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* prevent FOUC */
transition: opacity 0.2s ease-in-out;
}
* {
box-sizing: border-box;
}
.font-sans-serif {
font-family: sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
:root {
${() => getColors()}
--not-quite-black: #23272A;
--dark-but-not-black: #2C2F33;
--greyple: #99AAB5;
--blurple: var(--c-discord);
}
::selection {
background: var(--c-9);
color: var(--c-1);
}
::-moz-selection {
background: var(--c-9);
color: var(--c-1);
}
html {
overflow: hidden;
height: 100%;
}
body {
margin: 0;
padding: 0;
height: 100%;
overflow: auto;
color: var(--c-white);
background-color: var(--c-1);
/* overflow-y: hidden; */
}
h1,h2,h3,h4,h5,h6 {
color: var(--c-9);
}
.fade-element {
opacity: 1;
transition: opacity 0.3s ease-in-out;
}
.fade {
opacity: 0;
}
`

View file

@ -0,0 +1,100 @@
// @flow
import * as React from 'react'
import HeaderBarCommon, { Logomark } from './common'
import { type User } from '../../stores/user'
import DiscordIcon from '../discord-guild-pic'
import styled from 'styled-components'
import { Hide } from '../../kit/media'
import Link from 'next/link'
import { connect } from 'react-redux'
import { getCurrentServerState } from '../../stores/currentServer'
const temporaryServer = {
id: '423497622876061707',
name: 'Placeholder',
icon: '8d03476c186ec8b2f6a1a4f5e55b13fe'
}
const LogoBox = styled.a`
flex: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
`
const StyledServerPic = styled(DiscordIcon)`
border-radius: 100%;
box-shadow: 0 0 1px rgba(0,0,0,0.1);
border: 1px solid rgba(0,0,0,0.25);
height: 35px;
width: 35px;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
`
const StyledAvatar = styled.img`
border-radius: 100%;
border: 1px solid var(--c-green);
height: 35px;
width: 35px;
margin-left: 10px;
display: flex;
align-items: center;
justify-content: center;
`
const ServerSelector = (props) => <div {...props}>
<div>
<StyledServerPic {...props} />
</div>
<div>
{ props.name }
</div>
</div>
const StyledServerSelector = styled(ServerSelector)`
flex: 1;
display: flex;
align-items: center;
justify-content: left;
`
const UserSection = ({ user, ...props }) => <div {...props}>
<Hide.SM>{ user.username }</Hide.SM>
<StyledAvatar src={user.avatar} />
</div>
const StyledUserSection = styled(UserSection)`
display: flex;
align-items: center;
justify-content: flex-end;
text-align: right;
`
const Spacer = styled.div`
flex: 1;
`
const HeaderBarAuth: React.StatelessFunctionalComponent<{ user: User, isOnServer: boolean, currentServer: * }> = ({ user, isOnServer, currentServer = temporaryServer }) => (
<HeaderBarCommon noBackground={false}>
<>
<Link href='/'>
<LogoBox>
<Logomark />
</LogoBox>
</Link>
{ isOnServer ? <StyledServerSelector name={currentServer.name} id={currentServer.id} icon={currentServer.icon} /> : <Spacer /> }
<StyledUserSection user={user} />
</>
</HeaderBarCommon>
)
const mapStateToProps = (state, { router }) => ({
isOnServer: router.pathname === '/_internal/_server',
currentServer: router.pathname === '/_internal/_server' ? getCurrentServerState(state, router.query.id).server : {}
})
export default connect(mapStateToProps)(HeaderBarAuth)

View file

@ -0,0 +1,60 @@
// @flow
import * as React from 'react'
import dynamic from 'next/dynamic'
import styled from 'styled-components'
import * as logo from '../logo'
export type CommonProps = {
children: React.Element<any>,
noBackground: boolean
}
const Header = styled.div`
background-color: ${({ noBackground }: any) => noBackground === false ? 'var(--c-dark);' : 'var(--c-1);'}
position: relative;
transition: background-color 0.3s ease-in-out;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 5;
`
const HeaderInner = styled.div`
display: flex;
justify-content: center;
align-items: center;
max-width: 960px;
width: 100vw;
margin: 0 auto;
height: 50px;
padding: 3px 5px;
position: relative;
top: -1px;
& > div {
margin: 0 0.5em;
}
`
export const Logotype = styled(logo.Logotype)`
height: 30px;
`
export const Logomark = styled(logo.Logomark)`
width: 40px;
height: 40px;
`
//
const DebugBreakpoints = dynamic(() => import('../../kit/debug-breakpoints'))
const HeaderBarCommon = ({ children, noBackground = false }: CommonProps) => (
<Header noBackground={noBackground}>
{ (process.env.NODE_ENV === 'development') && <DebugBreakpoints />}
<HeaderInner>
{ children }
</HeaderInner>
</Header>
)
export default HeaderBarCommon

View file

@ -0,0 +1,53 @@
// @flow
import * as React from 'react'
import HeaderBarCommon, { Logotype, type CommonProps } from './common'
import styled from 'styled-components'
import Link from 'next/link'
const LogoBox = styled.a`
flex: 1;
display: flex;
align-items: center;
justify-content: flex-start;
cursor: pointer;
`
const LoginButton = styled.a`
cursor: pointer;
background-color: transparent;
display: block;
padding: 0.3em 1em;
border-radius: 2px;
border: 1px solid transparent;
transition: all 0.3s ease-in-out;
&:hover {
transform: translateY(-1px);
box-shadow: 0 1px 2px rgba(0,0,0,0.75);
background-color: var(--c-green);
border-color: rgba(0,0,0,0.25);
text-shadow: 1px 1px 0px rgba(0,0,0,0.25);
}
&:active {
transform: translateY(0px);
box-shadow: none;
}
`
const HeaderBarUnauth: React.StatelessFunctionalComponent<CommonProps> = (props) => (
<HeaderBarCommon {...props}>
<>
<Link href='/' prefetch>
<LogoBox>
<Logotype />
</LogoBox>
</Link>
<Link href='/auth/login' prefetch>
<LoginButton>Sign in </LoginButton>
</Link>
</>
</HeaderBarCommon>
)
export default HeaderBarUnauth

View file

@ -0,0 +1,37 @@
// @flow
import * as React from 'react'
import GlobalColors from './global-colors'
import SocialCards from './social-cards'
import HeaderBar from '../containers/header-bar'
import { type User } from '../stores/user'
import styled from 'styled-components'
const LayoutWrapper = styled.div`
transition: opacity: 0.1s ease-out;
opacity: 0;
.wf-active &, .force-active & {
opacity: 1;
}
`
const ContentBox = styled.div`
margin: 0 auto;
width: 960px;
max-width: 100vw;
padding: 5px;
padding-top: 50px;
/* max-height: calc(100vh - 50px); */
`
const Layout = ({ children, user, noBackground, router }: {children: React.Element<any>, user: User, noBackground: boolean, router: * }) => <>
<GlobalColors />
<SocialCards />
<LayoutWrapper>
<HeaderBar user={user} noBackground={noBackground} router={router} />
<ContentBox>
{children}
</ContentBox>
</LayoutWrapper>
</>
export default Layout

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,78 @@
/* eslint-env jest */
import * as React from 'react'
// import renderer from 'react-test-renderer'
import { shallow } from 'enzyme'
import Role from '../index'
import 'jest-styled-components'
describe('<Role />', () => {
it('renders correctly', () => {
const role = shallow(<Role role={{ name: 'Test Role', color: '#ffffff' }} />)
expect(role).toMatchSnapshot()
})
it('triggers onToggle with new state', () => {
let changed = false
const role = shallow(
<Role
role={{ name: 'Test Role', color: '#ffffff' }}
onToggle={(next) => { changed = next }}
active={false}
/>
)
role.simulate('click')
expect(changed).toBe(true)
const role2 = shallow(
<Role
role={{ name: 'Test Role', color: '#ffffff' }}
onToggle={(next) => { changed = next }}
active
/>
)
role2.simulate('click')
expect(changed).toBe(false)
})
it('fixes colors when they are not set', () => {
const role = shallow(<Role role={{ name: 'Test Role', color: 0 }} />)
expect(role.props().style['--role-color-base']).toEqual('hsl(0, 0%, 93.7%)')
})
it('has a single space for a name when empty', () => {
const role = shallow(<Role role={{ name: '', color: '#ffffff' }} />)
expect(role.text()).toEqual(' ')
})
describe('when disabled,', () => {
it('handles touch hover events', () => {
const role = shallow(<Role role={{ name: 'unsafe role', color: '#ffffff' }} disabled />)
role.simulate('touchstart')
expect(role.state().hovering).toEqual(true)
expect(role).toMatchSnapshot() // expecting tooltip
expect(role.exists('tooltip')).toEqual(true)
role.simulate('touchend')
expect(role.state().hovering).toEqual(false)
})
it('does not trigger onToggle on click', () => {
let changed = false
const role = shallow(
<Role
role={{ name: 'Test Role', color: '#ffffff' }}
onToggle={() => { changed = true }}
active={changed}
disabled
/>
)
expect(role).toMatchSnapshot()
role.simulate('click')
expect(role.html()).toBe(role.html())
expect(changed).toBe(false)
})
})
})

View file

@ -0,0 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Role /> renders correctly 1`] = `
<rolestyled
onClick={[Function]}
onTouchEnd={null}
onTouchStart={null}
style={
Object {
"--role-color-active": "hsl(0, 0%, 100%)",
"--role-color-base": "hsl(0, 0%, 100%)",
"--role-color-outline": "hsla(0, 0%, 100%, 0.7)",
"--role-color-outline-alt": "hsla(0, 0%, 100%, 0.4)",
}
}
title={null}
>
Test Role
</rolestyled>
`;
exports[`<Role /> when disabled, does not trigger onToggle on click 1`] = `
<rolestyled
active={false}
disabled={true}
onClick={null}
onTouchEnd={[Function]}
onTouchStart={[Function]}
style={
Object {
"--role-color-active": "hsl(0, 0%, 100%)",
"--role-color-base": "hsl(0, 0%, 100%)",
"--role-color-outline": "hsla(0, 0%, 100%, 0.7)",
"--role-color-outline-alt": "hsla(0, 0%, 100%, 0.4)",
}
}
title="This role has unsafe permissions."
>
Test Role
</rolestyled>
`;
exports[`<Role /> when disabled, handles touch hover events 1`] = `
<rolestyled
disabled={true}
onClick={null}
onTouchEnd={[Function]}
onTouchStart={[Function]}
style={
Object {
"--role-color-active": "hsl(0, 0%, 100%)",
"--role-color-base": "hsl(0, 0%, 100%)",
"--role-color-outline": "hsla(0, 0%, 100%, 0.7)",
"--role-color-outline-alt": "hsla(0, 0%, 100%, 0.4)",
}
}
title="This role has unsafe permissions."
>
unsafe role
<tooltip>
This role has unsafe permissions.
</tooltip>
</rolestyled>
`;

View file

@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<RoleDemo /> renders 1`] = `
<Role
active={false}
onToggle={[Function]}
role={
Object {
"color": "#ffffff",
"name": "test demo role",
}
}
/>
`;

View file

@ -0,0 +1,19 @@
/* eslint-env jest */
import * as React from 'react'
import { shallow } from 'enzyme'
import RoleDemo from '../demo'
import 'jest-styled-components'
describe('<RoleDemo />', () => {
it('renders', () => {
const demo = shallow(<RoleDemo role={{ name: 'test demo role', color: '#ffffff' }} />)
expect(demo).toMatchSnapshot()
})
it('changes state when clicked', () => {
const demo = shallow(<RoleDemo role={{ name: 'test demo role', color: '#ffffff' }} />)
expect(demo.state().active).toEqual(false)
demo.dive().simulate('click')
expect(demo.state().active).toEqual(true)
})
})

View file

@ -0,0 +1,25 @@
// @flow
import * as React from 'react'
import Role, { type RoleData } from './index'
export type DemoRoleProps = {
role: RoleData
}
type DemoRoleState = {
active: boolean
}
export default class RoleDemo extends React.Component<DemoRoleProps, DemoRoleState> {
state = {
active: false
}
onToggle = (n: boolean) => {
this.setState({ active: n })
}
render () {
return <Role role={this.props.role} onToggle={this.onToggle} active={this.state.active} />
}
}

View file

@ -0,0 +1,88 @@
// @flow
import * as React from 'react'
import { colors } from '../global-colors'
import Color from 'color'
import RoleStyled from './role.styled'
import Tooltip from '../tooltip'
// import logger from '../../../logger'
// const log = logger(__filename)
const fromColors = (colors) => Object.entries(colors).reduce(
(acc, [v, val]) => ({ ...acc, [`--role-color-${v}`]: val })
, {})
export type RoleData = {
color: string,
name: string,
}
export type RoleProps = {
active?: boolean, // is lit up as if it's in use
disabled?: boolean, // is interaction-disabled
type?: 'drag' | 'button',
role: RoleData,
isDragging?: boolean,
onToggle?: (nextState: boolean, lastState: boolean) => void,
connectDragSource?: (component: React.Node) => void
}
// const tooltip = ({ show = true, text, ...rest }) => <div {...rest}>{text}</div>
type RoleState = {
hovering: boolean
}
export default class Role extends React.Component<RoleProps, RoleState> {
state = {
hovering: false
}
onToggle = () => {
if (!this.props.disabled && this.props.onToggle) {
const { active = false } = this.props
this.props.onToggle(!active, active)
}
}
onMouseOver = () => {
// log.debug('caught hovering')
if (this.props.disabled && this.state.hovering === false) {
// log.debug('set hovering')
this.setState({ hovering: true })
}
}
onMouseOut = () => {
// log.debug('out hovering')
this.setState({ hovering: false })
}
render () {
let color = Color(this.props.role.color)
if (this.props.role.color === 0) {
color = colors.white
}
const roleColors = {
'outline': Color(color).alpha(0.7).hsl().string(),
'outline-alt': Color(color).alpha(0.4).hsl().string(),
'active': Color(color).lighten(0.1).hsl().string(),
'base': Color(color).hsl().string()
}
const name = (this.props.role.name !== '') ? this.props.role.name : ' '
return <RoleStyled
active={this.props.active}
disabled={this.props.disabled}
onClick={(this.props.disabled) ? null : this.onToggle}
onTouchStart={(this.props.disabled) ? this.onMouseOver : null}
onTouchEnd={(this.props.disabled) ? this.onMouseOut : null}
style={fromColors(roleColors)}
title={(this.props.disabled) ? 'This role has unsafe permissions.' : null}
>
{name}
{ (this.props.disabled && this.state.hovering) && <Tooltip>This role has unsafe permissions.</Tooltip> }
</RoleStyled>
}
}

View file

@ -0,0 +1,124 @@
import styled from 'styled-components'
import MediaQuery from '../../kit/media'
export default styled.div`
border: solid 1px var(--role-color-outline);
border-radius: 1.2em;
box-sizing: border-box;
cursor: pointer;
position: relative;
display: flex;
overflow: hidden;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
flex-direction: column;
font-size: 1.2em;
line-height: 20px;
margin: 0.3em;
padding: 4px 0.5em;
min-height: 32px;
max-width: 90vw;
transition: box-shadow 0.3s ease-in-out;
text-shadow: 1px 1px 1px rgba(0,0,0,0.45);
text-overflow: ellipsis;
user-select: none;
white-space: nowrap;
transform: rotateZ(0);
${(props: any) => (props.active) ? `
box-shadow: inset 0 0 0 3em var(--role-color-outline-alt);
` : `
`}
.wf-active & {
/* padding-top: 4px; */
}
&[disabled]:hover {
overflow: visible;
}
&:hover::after {
transform: translateY(-1px) rotateZ(0);
box-shadow: 0 0 1px rgba(0,0,0,0.75);
border-color: var(--role-color-active);
clip-path: border-box circle(50.2% at 49.6% 50%); /* firefox fix */
}
&:active::after {
transform: none;
}
&::after {
content: '';
display: none;
box-sizing: border-box;
position: absolute;
left: 4px;
bottom: 2px;
top: 4px;
width: 22px;
height: 22px;
border: 1px solid var(--role-color-base);
border-radius: 100%;
clip-path: border-box circle(50.2% at 50% 50%); /* this is just for you, firefox. */
transform: rotateZ(0);
${(props: any) => (props.active) ? `
transition: border 0.3s ease-in-out, transform 0.1s ease-in-out, background-color 1ms ease-in-out 0.29s;
border-left-width: 21px;
` : `
transition: border 0.3s ease-in-out, transform 0.1s ease-in-out, background-color 0.3s ease-in-out;
`}
}
${(props: any) => MediaQuery({
md: `
font-size: 1em;
text-shadow: none;
padding-left: 32px;
${(props.active) ? `box-shadow: none;` : ''}
&::after {
${(props.active) ? `background-color: var(--role-color-base);` : ''}
display: block;
}
&:hover::after {
${(props.active) ? `background-color: var(--role-color-active);` : ''}
}
`
})}
&[disabled] {
border-color: hsl(0,0%,40%);
color: hsla(0,0%,40%,0.7);
cursor: default;
box-shadow: none;
${(props: any) => (props.active) ? `
box-shadow: inset 0 0 0 3em hsla(0,0%,40%,0.1);
background-color: hsl(0,0%,40%);`
: ``};
&::after {
border-color: hsl(0,0%,40%);
}
&:hover::after {
border-color: hsl(0,0%,40%);
transform: none;
box-shadow: none;
}
}
`

View file

@ -0,0 +1,36 @@
// @flow
import * as React from 'react'
import NextHead from 'next/head'
export type SocialCardProps = {
title?: string,
description?: string,
image?: string,
imageSize?: number
}
const defaultProps: SocialCardProps = {
title: 'Roleypoly',
description: 'Tame your Discord roles.',
image: 'https://rp.kat.cafe/static/social.png',
imageSize: 200
}
const SocialCards = (props: SocialCardProps) => {
props = {
...defaultProps,
...props
}
return <NextHead>
<meta key='og:title' property='og:title' content={props.title} />
<meta key='og:description' property='og:description' content={props.description} />
<meta key='twitter:card' name='twitter:card' content='summary_large_image' />
<meta key='twitter:image' name='twitter:image' content={props.image} />
<meta key='og:image' property='og:image' content={props.image} />
<meta key='og:image:width' property='og:image:width' content={props.imageSize} />
<meta key='og:image:height' property='og:image:height' content={props.imageSize} />
</NextHead>
}
export default SocialCards

View file

@ -0,0 +1,22 @@
import styled from 'styled-components'
import MediaQuery from '../kit/media'
export default styled.div`
position: absolute;
bottom: 35px;
font-size: 0.9em;
background-color: rgba(0,0,0,0.50);
padding: 5px;
color: var(--c-red);
border-radius: 3px;
border: 1px black solid;
z-index: 10;
opacity: 0.99;
overflow: auto;
pointer-events: none;
/* max-width: 50vw; */
white-space: normal;
${() => MediaQuery({ md: `
white-space: nowrap; `
})}
`

View file

@ -0,0 +1,8 @@
export default [
'cute',
'vanity',
'brave',
'proud',
'wonderful',
'日本語'
].map((name, i, roles) => ({ name: `a ${name} role ♡`, color: `hsl(${(360 / roles.length) * i},40%,70%)` }))

View file

@ -0,0 +1,17 @@
import { createStore, applyMiddleware } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import thunkMiddleware from 'redux-thunk'
import withNextRedux from 'next-redux-wrapper'
import { rootReducer } from 'fast-redux'
export const initStore = (initialState = {}) => {
return createStore(
rootReducer,
initialState,
composeWithDevTools(applyMiddleware(thunkMiddleware))
)
}
export const withRedux = (comp) => withNextRedux(initStore, {
debug: process.env.NODE_ENV === 'development'
})(comp)

View file

@ -0,0 +1,13 @@
// @flow
import RPCClient from '../rpc'
const client = new RPCClient({ forceDev: false })
export default client.rpc
export const withCookies = (ctx: any) => {
if (ctx.req != null) {
return client.withCookies(ctx.req.headers.cookie)
} else {
return client.rpc
}
}

View file

@ -0,0 +1,21 @@
// @flow
import * as React from 'react'
import dynamic from 'next/dynamic'
import { type User } from '../stores/user'
type Props = {
user: ?User
}
const HeaderBarAuth = dynamic(() => import('../components/header/auth'))
const HeaderBarUnauth = dynamic(() => import('../components/header/unauth'))
const HeaderBar = (props: Props) => {
if (props.user == null) {
return <HeaderBarUnauth {...props} />
} else {
return <HeaderBarAuth {...props} />
}
}
export default HeaderBar

View file

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MediaQuery outputs media queries 1`] = `
"@media screen and (min-width: 0px) {
font-size: 0.5em;
};
@media screen and (min-width: 544px) {
font-size: 1em;
};
@media screen and (min-width: 768px) {
font-size: 1.5em;
};
@media screen and (min-width: 1012px) {
font-size: 2em;
};
@media screen and (min-width: 1280px) {
font-size: 2.5em;
};"
`;

View file

@ -0,0 +1,16 @@
/* eslint-env jest */
import MediaQuery from '../media'
describe('MediaQuery', () => {
it('outputs media queries', () => {
const mq = MediaQuery({
xs: 'font-size: 0.5em;',
sm: 'font-size: 1em;',
md: 'font-size: 1.5em;',
lg: 'font-size: 2em;',
xl: 'font-size: 2.5em;'
})
expect(mq).toMatchSnapshot()
})
})

View file

@ -0,0 +1,31 @@
// @flow
import * as React from 'react'
import styled from 'styled-components'
import MediaQuery, { breakpoints } from './media'
const BreakpointDebugFloat = styled.div`
position: absolute;
bottom: 0em;
left: 0;
pointer-events: none;
height: 1.4em;
opacity: 0.4;
font-family: monospace;
`
const Breakpoint = styled.div`
padding: 0.1em;
display: none;
width: 1.5em;
text-align: center;
background-color: hsl(${(props: any) => props.hue}, 50%, 50%);
${(props: any) => MediaQuery({ [props.bp]: `display: inline-block` })}
`
const DebugFloater = () => {
return <BreakpointDebugFloat>
{ Object.keys(breakpoints).map((x, i, a) => <Breakpoint key={x} bp={x} hue={(360 / a.length) * i}>{x}</Breakpoint>) }
</BreakpointDebugFloat>
}
export default DebugFloater

View file

@ -0,0 +1,74 @@
// @flow
import styled from 'styled-components'
export type MediaQueryConfig = $Shape<{
xs: string,
sm: string,
md: string,
lg: string,
xl: string
}>
export const breakpoints = {
xs: 0,
sm: 544,
md: 768,
lg: 1012,
xl: 1280
}
const MediaQuery = (mq: MediaQueryConfig) => {
const out = []
for (const size in mq) {
if (breakpoints[size] == null) {
continue
}
const inner = mq[size]
out.push(`@media screen and (min-width: ${breakpoints[size]}px) {\n${inner}\n};`)
}
return out.join('\n')
}
export default MediaQuery
export const Hide = {
XS: styled.div`
display: none;
${() => MediaQuery({ sm: `display: block` })}
`,
SM: styled.div`
display: none;
${() => MediaQuery({ md: `display: block` })}
`,
MD: styled.div`
display: none;
${() => MediaQuery({ lg: `display: block` })}
`,
LG: styled.div`
display: none;
${() => MediaQuery({ xl: `display: block` })}
`
}
export const Show = {
XS: styled.div`
display: block;
${() => MediaQuery({ sm: `display: none` })}
`,
SM: styled.div`
display: block;
${() => MediaQuery({ md: `display: none` })}
`,
MD: styled.div`
display: block;
${() => MediaQuery({ lg: `display: none` })}
`,
LG: styled.div`
display: block;
${() => MediaQuery({ xl: `display: none` })}
`
}

View file

@ -0,0 +1,13 @@
import Router from 'next/router'
export default (context, target) => {
if (context && context.res) {
// server
// 303: "See other"
context.res.writeHead(303, { Location: target })
context.res.end()
} else {
// In the browser, we just pretend like this never even happened ;)
Router.replace(target)
}
}

View file

@ -0,0 +1,112 @@
// @flow
import * as React from 'react'
import App, { Container } from 'next/app'
import Head from 'next/head'
import Layout from '../components/layout'
import { withCookies } from '../config/rpc'
import { Provider } from 'react-redux'
import ErrorP, { Overlay } from './_error'
import styled from 'styled-components'
import { withRedux } from '../config/redux'
import type { UserPartial } from '../../services/discord'
type NextPage = React.Component<any> & React.StatelessFunctionalComponent<any> & {
getInitialProps: (ctx: any, ...args: any) => any
}
const MissingJS = styled.noscript`
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
font-size: 1.3em;
padding: 1em;
`
class RoleypolyApp extends App {
static async getInitialProps ({ Component, ctx, router }: { Component: NextPage, router: any, ctx: {[x:string]: any}}) {
// Fix for next/error rendering instead of our error page.
// Who knows why this would ever happen.
if (Component.displayName === 'ErrorPage' || Component.constructor.name === 'ErrorPage') {
Component = ErrorP
}
// console.log({ Component })
let pageProps = {}
const rpc = withCookies(ctx)
let user: ?UserPartial
try {
user = await rpc.getCurrentUser()
ctx.user = user
} catch (e) {
if (e.code === 403) {
ctx.user = null
} else {
console.error(e)
throw e
}
}
ctx.robots = 'INDEX, FOLLOW'
ctx.layout = {
noBackground: false
}
if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx, rpc, router)
}
// console.log({ pageProps })
return { pageProps, user, layout: ctx.layout, robots: ctx.robots }
}
catchFOUC () {
setTimeout(() => {
if (document.documentElement) document.documentElement.className += ' force-active'
}, 700)
}
componentDidMount () {
this.catchFOUC()
}
render () {
const { Component, pageProps, router, user, layout, robots, store } = this.props
// Fix for next/error rendering instead of our error page.
// Who knows why this would ever happen.
const ErrorCaughtComponent = (Component.displayName === 'ErrorPage' || Component.constructor.name === 'ErrorPage') ? ErrorP : Component
return <Container>
<MissingJS>
<Overlay />
Hey there... Unfortunately, we require JS for this app to work. Please take this rose as retribution. 🌹
</MissingJS>
<Head>
<meta charSet='utf-8' />
<title key='title'>Roleypoly</title>
<meta name='viewport' content='width=device-width, initial-scale=1' />
<link rel='icon' href='/static/favicon.png' />
<meta key='robots' name='robots' content={robots} />
<script key='typekit' dangerouslySetInnerHTML={{ __html: `
(function(d) {
var config = {
kitId: 'bck0pci',
scriptTimeout: 1500,
async: true
},
h=d.documentElement,t=setTimeout(function(){h.className=h.className.replace(/\bwf-loading\b/g,"")+" wf-inactive";},config.scriptTimeout),tk=d.createElement("script"),f=false,s=d.getElementsByTagName("script")[0],a;h.className+=" wf-loading";tk.src='https://use.typekit.net/'+config.kitId+'.js';tk.async=true;tk.onload=tk.onreadystatechange=function(){a=this.readyState;if(f||a&&a!="complete"&&a!="loaded")return;f=true;clearTimeout(t);try{Typekit.load(config)}catch(e){}};s.parentNode.insertBefore(tk,s)
})(document);//
` }} />
</Head>
<Provider store={store}>
<Layout user={user} {...layout} router={router}>
<ErrorCaughtComponent {...pageProps} router={router} originalName={Component.displayName || Component.constructor.name} />
</Layout>
</Provider>
</Container>
}
}
export default withRedux(RoleypolyApp)

View file

@ -0,0 +1,24 @@
import Document from 'next/document'
import { ServerStyleSheet } from 'styled-components'
export default class MyDocument extends Document {
static async getInitialProps (ctx) {
const sheet = new ServerStyleSheet()
const originalRenderPage = ctx.renderPage
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: App => props => sheet.collectStyles(<App {...props} />)
})
const initialProps = await Document.getInitialProps(ctx)
return {
...initialProps,
styles: <>{initialProps.styles}{sheet.getStyleElement()}</>
}
} finally {
sheet.seal()
}
}
}

View file

@ -0,0 +1,110 @@
import * as React from 'react'
import styled from 'styled-components'
import MediaQuery from '../kit/media'
export const Overlay = styled.div`
opacity: 0.6;
pointer-events: none;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: -10;
background-image: radial-gradient(circle, var(--c-dark), var(--c-dark) 1px, transparent 1px, transparent);
background-size: 27px 27px;
`
const ResponsiveSplitter = styled.div`
z-index: -1;
display: flex;
align-items: center;
justify-content: center;
line-height: 1.6;
font-size: 1.3em;
flex-direction: column;
${() => MediaQuery({
md: `flex-direction: row; min-height: 100vh; position: relative; top: -50px;`
})}
& > div {
margin: 1rem;
}
& section {
text-align: center;
${() => MediaQuery({
md: `text-align: left;`
})}
}
`
const JapaneseFlair = styled.section`
color: var(--c-3);
font-size: 0.9rem;
`
const Code = styled.h1`
margin: 0;
padding: 0;
font-size: 4em;
${() => MediaQuery({
md: `font-size: 2em;`
})}
`
export default class CustomErrorPage extends React.Component {
static getInitialProps ({ res, err, robots }) {
const statusCode = res ? res.statusCode : err ? err.statusCode : null
robots = 'NOINDEX, NOFOLLOW'
return { statusCode }
}
render400 = () => this.out('400', `Your client sent me something weird...`, '((((;゜Д゜)))')
render403 = () => this.out('403', `You weren't allowed to access this.`, 'あなたはこの点に合格しないかもしれません')
render404 = () => this.out('404', 'This page is in another castle.', 'お探しのページは見つかりませんでした')
render419 = () => this.out('419', 'Something went too slowly...', 'おやすみなさい〜')
render500 = () => this.out('500', `The server doesn't like you right now. Feed it a cookie.`, 'クッキーを送ってください〜 クッキーを送ってください〜')
renderDefault = () => this.out('Oops', 'Something went bad. How could this happen?', 'おねがい?')
renderServer = () => this.out('Oops.', 'Server was unhappy about this render. Try reloading or changing page.', 'クッキーを送ってください〜')
renderAuthExpired = () => this.out('Woah.', 'That magic login link was expired.', 'What are you trying to do?')
out (code, description, flair) {
return <div>
<Overlay />
<ResponsiveSplitter>
<div>
<Code>{code}</Code>
</div>
<div>
<section>
{description}
</section>
<JapaneseFlair>{flair}</JapaneseFlair>
</div>
</ResponsiveSplitter>
</div>
}
handlers = {
400: this.render400,
403: this.render403,
404: this.render404,
419: this.render419,
500: this.render500,
1001: this.renderAuthExpired
}
render () {
// if (this.props.originalName === 'ErrorPage') {
// return this.renderServer()
// }
if (this.props.statusCode in this.handlers) {
return this.handlers[this.props.statusCode]()
}
return this.renderDefault()
}
}

View file

@ -0,0 +1,6 @@
import * as React from 'react'
import SocialCards from '../../../components/social-cards'
export default () => <>
<SocialCards title='Sign in on Roleypoly' />
</>

View file

@ -0,0 +1,6 @@
import * as React from 'react'
import SocialCards from '../../../components/social-cards'
export default () => <>
<SocialCards title='Sign in on Roleypoly' description="Click this link to log in. It's magic!" />
</>

View file

@ -0,0 +1,111 @@
// @flow
import * as React from 'react'
import Head from 'next/head'
import type { PageProps } from '../../types'
import SocialCards from '../../components/social-cards'
import redirect from '../../lib/redirect'
import { connect } from 'react-redux'
import { fetchServerIfNeed, getCurrentServerState, type ServerState } from '../../stores/currentServer'
import { renderRoles, getCategoryViewState, toggleRole, type ViewState } from '../../stores/roles'
import styled from 'styled-components'
import Role from '../../components/role'
type ServerPageProps = PageProps & {
currentServer: ServerState,
view: ViewState,
isDiscordBot: boolean
}
const mapStateToProps = (state, { router: { query: { id } } }) => {
return {
currentServer: getCurrentServerState(state, id),
view: getCategoryViewState(state)
}
}
const Category = styled.div``
const Hider = styled.div`
/* opacity: ${(props: any) => props.visible ? '1' : '0'}; */
/* opacity: 1; */
/* transition: opacity 0.15s ease-out; */
/* ${(props: any) => props.visible ? '' : 'display: none;'} */
`
const RoleHolder = styled.div`
display: flex;
flex-wrap: wrap;
`
class Server extends React.PureComponent<ServerPageProps> {
static async getInitialProps (ctx: *, rpc: *, router: *) {
const isDiscordBot = ctx.req && ctx.req.headers['user-agent'].includes('Discordbot')
if (ctx.user == null) {
if (!isDiscordBot) {
redirect(ctx, `/auth/login?r=${router.asPath}`)
}
}
ctx.robots = 'NOINDEX, NOFOLLOW'
await ctx.store.dispatch(fetchServerIfNeed(router.query.id, rpc))
if (!isDiscordBot) {
await ctx.store.dispatch(renderRoles(router.query.id))
}
return { isDiscordBot }
}
async componentDidMount () {
const { currentServer, router: { query: { id } }, dispatch } = this.props
if (currentServer == null) {
this.props.router.push('/s/add')
}
await dispatch(fetchServerIfNeed(id))
await dispatch(renderRoles(id))
}
onToggle = (role) => (nextState) => {
if (role.safe) {
this.props.dispatch(toggleRole(role.id, nextState))
}
}
renderSocial () {
const { currentServer } = this.props
return <SocialCards title={`${currentServer.server.name} on Roleypoly`} description='Manage your roles here.' />
}
render () {
const { isDiscordBot, currentServer, view } = this.props
// console.log({ currentServer })
if (currentServer == null) {
return null
}
if (isDiscordBot) {
return this.renderSocial()
}
return (
<div>
<Head>
<title key='title'>{currentServer.server.name} - Roleypoly</title>
</Head>
{ this.renderSocial() }
hello <span style={{ color: currentServer.gm?.color }}>{currentServer.gm?.nickname}</span> on {currentServer.server.name} ({ view.dirty ? 'dirty' : 'clean' })
<Hider visible={true || currentServer.id !== null}>
{ !view.invalidated && view.categories.map(c => <Category key={`cat__${c.name}__${c.id}`}>
<div>{ c.name }</div>
<RoleHolder>
{
c._roles && c._roles.map(r => <Role key={`role__${r.name}__${r.id}`} role={r} active={view.selected.includes(r.id)} onToggle={this.onToggle(r)} disabled={!r.safe} />)
}
</RoleHolder>
</Category>) }
</Hider>
</div>
)
}
}
export default connect(mapStateToProps)(Server)

View file

@ -0,0 +1 @@
export default () => <h1>s/add</h1>

View file

@ -0,0 +1,2 @@
import Error from '../_error'
export default ({ router: { query: { code = 404 } } }) => <Error statusCode={code} />

View file

@ -0,0 +1,23 @@
// @flow
import * as React from 'react'
import type { PageProps } from '../../types'
export default class LandingTest extends React.Component<PageProps> {
render () {
return <div>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Suscipit accusantium quidem adipisci excepturi, delectus iure omnis, eos corrupti, ea ex iusto magnam! Incidunt accusamus repellat natus esse facilis animi aut.</p>
</div>
}
}

View file

@ -0,0 +1,3 @@
import ErrorPage from '../_error'
export default (props) => <ErrorPage {...props} statusCode={1001} />

View file

@ -0,0 +1,192 @@
// @flow
import * as React from 'react'
import styled from 'styled-components'
import MediaQuery from '../../kit/media'
import DiscordButton from '../../components/discord-button'
import RPC from '../../config/rpc'
import redirect from '../../lib/redirect'
import dynamic from 'next/dynamic'
import type { PageProps, ServerSlug } from '../../types'
import getConfig from 'next/config'
const { publicRuntimeConfig: { BOT_HANDLE } } = getConfig()
type AuthLoginState = {
humanCode: string,
waiting: boolean
}
type AuthLoginProps = PageProps & {
redirect: ?string,
redirectSlug: ?ServerSlug
}
const Wrapper = styled.div`
display: flex;
justify-content: center;
padding-top: 3em;
width: 400px;
max-width: calc(98vw - 10px);
margin: 0 auto;
text-align: center;
${() => MediaQuery({
md: `
padding-top: 0;
align-items: center;
min-height: 80vh;
`
})}
`
const Line = styled.div`
height: 1px;
background-color: var(--c-9);
margin: 1em 0.3em;
`
const SecretCode = styled.input`
background-color: transparent;
border: 0;
padding: 1em;
color: var(--c-9);
margin: 0.5rem 0;
width: 100%;
font-size: 0.9em;
appearance: none;
transition: all 0.3s ease-in-out;
&:focus, &:active, &:hover {
background-color: var(--c-3);
}
&:focus, &:active {
& ::placeholder {
color: transparent;
}
}
& ::placeholder {
transition: all 0.3s ease-in-out;
color: var(--c-7);
text-align: center;
}
`
const HiderButton = styled.button`
appearance: none;
display: block;
cursor: pointer;
width: 100%;
background-color: var(--c-3);
color: var(--c-white);
border: none;
padding: 1em;
font-size: 0.9em;
transition: all 0.3s ease-in-out;
&[disabled] {
cursor: default;
opacity: 0;
pointer-events: none;
}
`
const SlugWrapper = styled.div`
padding-bottom: 2em;
text-align: center;
`
const DiscordGuildPic = dynamic(() => import('../../components/discord-guild-pic'))
const StyledDGP = styled(DiscordGuildPic)`
border-radius: 100%;
border: 2px solid rgba(0,0,0,0.2);
height: 4em;
margin-top: 1em;
`
const ServerName = styled.span`
font-weight: bold;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 370px;
display: block;
`
const Slug = (slug: ServerSlug) => <SlugWrapper>
<StyledDGP {...slug} />
<br />Hey there.<br /><ServerName>{slug.name}</ServerName> uses Roleypoly to manage its roles.
</SlugWrapper>
export default class AuthLogin extends React.Component<AuthLoginProps, AuthLoginState> {
state = {
humanCode: '',
waiting: false
}
static async getInitialProps (ctx: *, rpc: typeof RPC, router: *) {
let { r } = (router.query: { r: string })
if (ctx.user != null) {
redirect(ctx, r || '/')
}
ctx.robots = 'NOINDEX, NOFOLLOW'
if (r != null) {
let redirectSlug = null
if (r.startsWith('/s/') && r !== '/s/add') {
redirectSlug = await rpc.getServerSlug(r.replace('/s/', ''))
}
return { redirect: r, redirectSlug }
}
}
componentDidMount () {
if (this.props.redirect != null) {
this.props.router.replace(this.props.router.pathname)
}
}
onChange = (event: any) => {
this.setState({ humanCode: event.target.value })
}
onSubmit = async () => {
this.setState({ waiting: true })
try {
const result = await RPC.checkAuthChallenge(this.state.humanCode)
if (result === true) {
redirect(null, this.props.redirect || '/')
}
} catch (e) {
this.setState({ waiting: false })
}
}
get dm () {
if (BOT_HANDLE) {
const [username, discrim] = BOT_HANDLE.split('#')
return <><b>{ username }</b>#{discrim}</>
}
return <><b>roleypoly</b>#3712</>
}
render () {
return <Wrapper>
<div>
{(this.props.redirectSlug != null) ? <Slug {...this.props.redirectSlug} /> : null}
<DiscordButton href={`/api/auth/redirect?r=${this.props.redirect || '/'}`}>Sign in with Discord</DiscordButton>
<Line />
<div>
<i>Or, send a DM to {this.dm} saying: login</i>
</div>
<div>
<SecretCode placeholder='click to enter super secret code' onChange={this.onChange} value={this.state.humanCode} />
<HiderButton onClick={this.onSubmit} disabled={this.state.humanCode === ''}>{
(this.state.waiting) ? 'One sec...' : 'Submit Code →'
}</HiderButton>
</div>
</div>
</Wrapper>
}
}

View file

@ -0,0 +1,113 @@
import * as React from 'react'
import styled from 'styled-components'
import demoRoles from '../../config/demo'
import MediaQuery from '../../kit/media'
const admin = { name: 'admin', color: '#db2828' }
const bot = { name: 'roleypoly', color: 'var(--c-5)' }
const exampleGood = [
admin,
bot,
...demoRoles
]
const exampleBad = [
admin,
...demoRoles,
bot
]
const DiscordOuter = styled.div`
background-color: var(--dark-but-not-black);
padding: 10px;
text-align: left;
color: var(--c-white);
border: 1px solid rgba(0,0,0,0.25);
width: 250px;
margin: 0 auto;
user-select: none;
`
const Collapser = styled.div`
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
${() => MediaQuery({
md: `flex-direction: row;`
})}
`
const DiscordRole = styled.div`
color: ${({ role: { color } }) => color};
position: relative;
padding: 0.3em 0.6em;
border-radius: 3px;
cursor: pointer;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
transition: opacity 0.02s ease-in-out;
opacity: 0;
background-color: ${({ role: { color } }) => color};
border-radius: 3px;
}
&:hover::before {
opacity: 0.1;
}
${
({ role }) => (role.name === 'roleypoly')
? `
background-color: ${role.color};
color: var(--c-white);
`
: ``
}
`
const MicroHeader = styled.div`
font-size: 0.7em;
font-weight: bold;
color: #72767d;
padding: 0.3rem 0.6rem;
`
const Center = styled.div`
text-align: center;
margin: 2em;
`
const Example = ({ data }) => (
<DiscordOuter>
<MicroHeader>
ROLES
</MicroHeader>
{
data.map(role => <DiscordRole role={role} key={role.name}>{role.name}</DiscordRole>)
}
</DiscordOuter>
)
export default () => <div>
<h3>How come I can't see any of my roles?!</h3>
<p>Discord doesn't let us act upon roles that are "higher" than Roleypoly's in the list. You must keep it's role higher than any role you may want to assign.</p>
<Collapser>
<Center>
<h4 style={{ color: 'var(--c-red)' }}>Bad </h4>
<Example data={exampleBad} />
</Center>
<Center>
<h4 style={{ color: 'var(--c-green)' }}>Good </h4>
<Example data={exampleGood} />
</Center>
</Collapser>
</div>

View file

@ -0,0 +1,119 @@
import * as React from 'react'
import redirect from '../lib/redirect'
// import Link from 'next/link'
// import Head from '../components/head'
// import Nav from '../components/nav'
import TypingDemo from '../components/demos/typing'
import TapDemo from '../components/demos/tap'
import styled from 'styled-components'
import MediaQuery from '../kit/media'
const HeroBig = styled.h1`
color: var(--c-7);
font-size: 1.8em;
`
const HeroSmol = styled.h1`
color: var(--c-5);
font-size: 1.1em;
`
const Hero = styled.div`
padding: 2em 0;
text-align: center;
`
const Footer = styled.p`
text-align: center;
font-size: 0.7em;
opacity: 0.3;
transition: opacity 0.3s ease-in-out;
&:hover {
opacity: 1;
}
&:active {
opacity: 1;
}
`
const FooterLink = styled.a`
font-style: none;
color: var(--c-7);
text-decoration: none;
transition: color 0.3s ease-in-out;
&:hover {
color: var(--c-5);
}
`
const DemoArea = styled.div`
display: flex;
flex-direction: column;
${() => MediaQuery({ md: `flex-direction: row;` })}
& > div {
flex: 1;
padding: 10px;
}
& > div > p {
text-align: center;
}
`
const Wrapper = styled.div`
flex-wrap: wrap;
${() => MediaQuery({
md: `
display: flex;
justify-content: center;
align-items: center;
height: 80vh;
min-height: 500px;
`
})}
`
export default class Home extends React.Component {
static async getInitialProps (ctx, rpc) {
if (ctx.user != null) {
redirect(ctx, '/s/add')
}
ctx.layout.noBackground = true
}
render () {
return <div>
<Wrapper>
<div>
<Hero>
<HeroBig>Discord roles for humans.</HeroBig>
<HeroSmol>Ditch bot commands once and for all.</HeroSmol>
</Hero>
<DemoArea>
<div>
<TypingDemo />
<p>What is this? 2005?</p>
</div>
<div>
<TapDemo />
<p>Just click or tap.</p>
</div>
</DemoArea>
</div>
</Wrapper>
<Footer>
© {new Date().getFullYear()}<br />
Made with &nbsp;
<img src='/static/flags.svg' style={{ height: '1em', opacity: 0.5 }} /><br />
<FooterLink target='_blank' href='https://ko-fi.com/roleypoly'>Ko-Fi</FooterLink>&nbsp;-&nbsp;
<FooterLink target='_blank' href='https://github.com/kayteh/roleypoly'>GitHub</FooterLink>&nbsp;-&nbsp;
<FooterLink target='_blank' href='https://discord.gg/PWQUVsd'>Discord</FooterLink>
</Footer>
</div>
}
}

View file

@ -0,0 +1,31 @@
import * as React from 'react'
import RPC, { withCookies } from '../config/rpc'
export default class TestRPC extends React.Component {
static async getInitialProps (ctx) {
const user = await withCookies(ctx).getCurrentUser()
console.log(user)
return {
user
}
}
async componentDidMount () {
window.$RPC = RPC
}
componentDidCatch (error, errorInfo) {
if (error) {
console.log(error, errorInfo)
}
}
render () {
if (this.props.user == null) {
return <div>hello stranger OwO</div>
}
const { username, avatar, discriminator } = this.props.user
return <div>hello, {username}#{discriminator} <img src={avatar} width={50} height={50} /></div>
}
}

View file

@ -0,0 +1,119 @@
// @flow
import superagent from 'superagent'
import RPCError from '../../rpc/_error'
export type RPCResponse = {
response?: mixed,
hash?: string,
// error stuff
error?: boolean,
msg?: string,
trace?: string
}
export type RPCRequest = {
fn: string,
args: mixed[]
}
export default class RPCClient {
dev: boolean = false
baseUrl: string
firstKnownHash: string
recentHash: string
cookieHeader: string
rpc: {
[fn: string]: (...args: any[]) => Promise<any>
} = {}
__rpcAvailable: Array<{
name: string,
args: number
}> = []
constructor ({ forceDev, baseUrl = '/api/_rpc' }: { forceDev?: boolean, baseUrl?: string } = {}) {
this.baseUrl = (process.env.APP_URL || '') + baseUrl
if (forceDev != null) {
this.dev = forceDev
} else {
this.dev = process.env.NODE_ENV === 'development'
}
this.rpc = new Proxy({}, {
get: this.__rpcCall,
has: this.__checkCall,
ownKeys: this.__listCalls,
delete: () => {}
})
if (this.dev) {
this.updateCalls()
}
}
withCookies = (h: string) => {
this.cookieHeader = h
return this.rpc
}
async updateCalls () {
// this is for development only. doing in prod is probably dumb.
const rsp = await superagent.get(this.baseUrl)
if (rsp.status !== 200) {
console.error(rsp)
return
}
const { hash, available } = rsp.body
this.__rpcAvailable = available
if (this.firstKnownHash == null) {
this.firstKnownHash = hash
}
this.recentHash = hash
// just kinda prefill. none of these get called anyway.
// and don't matter in prod either.
for (let { name } of available) {
this.rpc[name] = async () => {}
}
}
async call (fn: string, ...args: any[]): mixed {
const req: RPCRequest = { fn, args }
const rq = superagent.post(this.baseUrl)
if (this.cookieHeader != null) {
rq.cookies = this.cookieHeader
}
const rsp = await rq.send(req).ok(() => true)
const body: RPCResponse = rsp.body
// console.log(body)
if (body.error === true) {
console.error(body)
throw RPCError.fromResponse(body, rsp.status)
}
if (body.hash != null) {
if (this.firstKnownHash == null) {
this.firstKnownHash = body.hash
}
this.recentHash = body.hash
if (this.firstKnownHash !== this.recentHash) {
this.updateCalls()
}
}
return body.response
}
// PROXY HANDLERS
__rpcCall = (_: {}, fn: string) => this.call.bind(this, fn)
__checkCall = (_: {}, fn: string) => this.dev ? this.__listCalls(_).includes(fn) : true
__listCalls = (_: {}): string[] => this.__rpcAvailable.map(x => x.name)
}

View file

@ -0,0 +1,4 @@
import { configure } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
configure({ adapter: new Adapter() })

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 245 240" style="enable-background:new 0 0 245 240;" xml:space="preserve">
<style type="text/css">
.st0{fill:#efefef;}
</style>
<g>
<path class="st0" d="M104.4,103.9c-5.7,0-10.2,5-10.2,11.1c0,6.1,4.6,11.1,10.2,11.1c5.7,0,10.2-5,10.2-11.1
C114.7,108.9,110.1,103.9,104.4,103.9z"/>
<path class="st0" d="M140.9,103.9c-5.7,0-10.2,5-10.2,11.1c0,6.1,4.6,11.1,10.2,11.1c5.7,0,10.2-5,10.2-11.1
C151.1,108.9,146.6,103.9,140.9,103.9z"/>
<path class="st0" d="M189.5,20H55.5C44.2,20,35,29.2,35,40.6v135.2c0,11.4,9.2,20.6,20.5,20.6h113.4l-5.3-18.5l12.8,11.9l12.1,11.2
L210,220v-44.2v-10V40.6C210,29.2,200.8,20,189.5,20z M150.9,150.6c0,0-3.6-4.3-6.6-8.1c13.1-3.7,18.1-11.9,18.1-11.9
c-4.1,2.7-8,4.6-11.5,5.9c-5,2.1-9.8,3.5-14.5,4.3c-9.6,1.8-18.4,1.3-25.9-0.1c-5.7-1.1-10.6-2.7-14.7-4.3c-2.3-0.9-4.8-2-7.3-3.4
c-0.3-0.2-0.6-0.3-0.9-0.5c-0.2-0.1-0.3-0.2-0.4-0.3c-1.8-1-2.8-1.7-2.8-1.7s4.8,8,17.5,11.8c-3,3.8-6.7,8.3-6.7,8.3
c-22.1-0.7-30.5-15.2-30.5-15.2c0-32.2,14.4-58.3,14.4-58.3c14.4-10.8,28.1-10.5,28.1-10.5l1,1.2c-18,5.2-26.3,13.1-26.3,13.1
s2.2-1.2,5.9-2.9c10.7-4.7,19.2-6,22.7-6.3c0.6-0.1,1.1-0.2,1.7-0.2c6.1-0.8,13-1,20.2-0.2c9.5,1.1,19.7,3.9,30.1,9.6
c0,0-7.9-7.5-24.9-12.7l1.4-1.6c0,0,13.7-0.3,28.1,10.5c0,0,14.4,26.1,14.4,58.3C181.5,135.4,173,149.9,150.9,150.6z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="3372px" height="900px" viewBox="0 0 3372 900" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 44.1 (41455) - http://www.bohemiancoding.com/sketch -->
<title>Trans</title>
<desc>Created with Sketch.</desc>
<defs>
<rect id="path-1" x="0" y="0" width="1600" height="900" rx="100"></rect>
<rect id="path-3" x="1772" y="0" width="1600" height="900" rx="100"></rect>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Rectangle-5"></g>
<g id="Trans" mask="url(#mask-2)">
<rect id="Rectangle" fill="#55CDFC" x="0" y="0" width="1600" height="900"></rect>
<rect id="Rectangle-2" fill="#F7A8B8" x="0" y="170" width="1600" height="560"></rect>
<rect id="Rectangle-3" fill="#FFFFFF" x="0" y="350" width="1600" height="200"></rect>
</g>
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<g id="Rectangle-5"></g>
<g id="Geyy" mask="url(#mask-4)">
<g transform="translate(1772.000000, 0.000000)" id="Rectangle-4">
<rect fill="#F9238B" x="0" y="0" width="1600" height="151.006711"></rect>
<rect fill="#FB7B04" x="0" y="150" width="1600" height="151.006711"></rect>
<rect fill="#FFCA66" x="0" y="300" width="1600" height="151.006711"></rect>
<rect fill="#00B289" x="0" y="450" width="1600" height="151.006711"></rect>
<rect fill="#5A38B5" x="0" y="598.993289" width="1600" height="151.006711"></rect>
<rect fill="#B413F5" x="0" y="748.993289" width="1600" height="151.006711"></rect>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,107 @@
// @flow
// import { action } from './servers'
import { namespaceConfig } from 'fast-redux'
// $FlowFixMe
import { OrderedMap, OrderedSet, Set } from 'immutable'
import { getCurrentServerState, type ServerState } from './currentServer'
type RenderedRole = {
id: string,
name: string,
color: string
}
type RenderedCategory = {
id: string,
name: string,
_roles?: RenderedRole[],
roles: string[],
type: 'single' | 'multi',
hidden: boolean,
position?: number,
}
const MOCK_DATA: RenderedCategory = {
id: '00000',
name: 'Placeholder Category',
hidden: false,
type: 'multi',
roles: [ '00000' ],
_roles: OrderedSet([
{ id: '00000', name: 'Placeholder Role', color: '#ff00ff' }
])
}
const DEFAULT_VIEW_STATE = { server: '000', invalidated: true, categories: [ MOCK_DATA ], selected: Set<string>([]), originalSelected: Set<string>([]), dirty: false }
export type ViewState = typeof DEFAULT_VIEW_STATE
export const { action, getState: getCategoryViewState } = namespaceConfig('currentCategoryView', DEFAULT_VIEW_STATE)
export const toggleRole = action('toggleRole', (state: ViewState, role: string, nextState: boolean) => {
let selected = state.selected
if (nextState === true) {
selected = selected.add(role)
} else {
selected = selected.delete(role)
}
const dirty = !selected.equals(state.originalSelected)
return {
...state,
selected,
dirty
}
})
const getUncategorized = (roleMap: OrderedMap<RenderedRole>, allCategories: Set): RenderedCategory => {
const roles = roleMap.keySeq().toSet()
const knownRoles = allCategories.map(v => v.roles).flatMap(v => v)
const rolesLeft = roles.subtract(knownRoles)
// console.log(Map({ roles, knownRoles, rolesLeft }).toJS())
return {
id: 'Uncategorized',
name: 'Uncategorized',
position: -1,
roles: rolesLeft,
_roles: rolesLeft.map(v => roleMap.get(v)).filter(v => v != null).sortBy(v => -v.position),
hidden: true,
type: 'multi'
}
}
export const updateCurrentView = action('updateCurrentView', (state, data) => ({ ...state, ...data }))
export const invalidateView = action('invalidateView', (state, data) => ({ ...state, invalidated: true }))
export const renderRoles = (id: string) => (dispatch: *, getState: *) => {
const active = getCategoryViewState(getState())
const current: ServerState = getCurrentServerState(getState(), id)
if (!active.invalidated && current.id === active.id && active.id === id) {
return
}
const { roles, categories } = current
if (roles == null) {
return
}
// console.log({ roles, categories })
const roleMap: OrderedMap<RenderedRole> = roles.reduce((acc: OrderedMap<RenderedRole>, r) => acc.set(r.id, r), OrderedMap())
let render = OrderedSet()
for (let catId in categories) {
const category = categories[catId]
category._roles = OrderedSet(
category.roles.map(r => roleMap.get(r))
).filter(role => role != null).sortBy(role => role ? -(role.position || 0) : 0)
render = render.add(category)
}
// console.log({id})
render = render.add(getUncategorized(roleMap, render.toSet()))
render = render.sortBy(h => (h.position) ? h.position : h.name)
dispatch(updateCurrentView({ server: id, categories: render, invalidated: false, selected: Set(current.gm?.roles), originalSelected: Set(current.gm?.roles) }))
}

View file

@ -0,0 +1,16 @@
// @flow
import { namespaceConfig } from 'fast-redux'
// import { Map } from 'immutable'
const DEFAULT_STATE = {}
export type ServersState = typeof DEFAULT_STATE
export const { action, getState: getServerState } = namespaceConfig('servers', DEFAULT_STATE)
export const updateServers = action('updateServers', (state: ServersState, serverData) => ({
...state,
servers: serverData
}))
export const updateSingleServer = action('updateSingleServer', (state, data, server) => ({ ...state, [server]: data }))

View file

@ -0,0 +1,32 @@
// @flow
import { namespaceConfig } from 'fast-redux'
export type User = {
id: string,
username: string,
discriminator: string,
avatar: string,
nicknameCache: {
[server: string]: string
}
}
export type UserState = {
currentUser: User | null,
userCache: {
[id: string]: User
}
}
const DEFAULT_STATE: UserState = {
currentUser: null,
userCache: {}
}
export const {
action, getState: getUserStore
} = namespaceConfig('userStore', DEFAULT_STATE)
export const getCurrentUser = () => async (dispatch: Function) => {
}

View file

@ -0,0 +1,10 @@
// @flow
import type { ServerSlug as BackendServerSlug } from '../services/presentation'
import type Router from 'next/router'
export type PageProps = {
router: Router,
dispatch: (...any) => mixed
}
export type ServerSlug = BackendServerSlug