mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
211 lines
7.6 KiB
TypeScript
211 lines
7.6 KiB
TypeScript
import {
|
|
AVATAR_STUDIO_ACCESS,
|
|
avatarStudioFlags,
|
|
getAccount,
|
|
getAccountByUsername,
|
|
listStudioAccounts,
|
|
updateAccount,
|
|
writeAuditLog,
|
|
} from '@repo/domain'
|
|
import { logger } from '@repo/hono-helpers'
|
|
import { validateAndGetAccountId } from '@repo/jwt'
|
|
|
|
import { hasStudioAccess } from '../../auth/src/platform-db'
|
|
|
|
import type { Context } from 'hono'
|
|
import type { Account } from '@repo/domain'
|
|
import type { App } from './context'
|
|
|
|
/**
|
|
* Studio upload access, as the website manages it.
|
|
*
|
|
* RecFlare Studio opens `/settings/recroomstudio` from the dialog that says the
|
|
* account cannot upload. That page reads {@link studioAccessStatusHandler} for
|
|
* the signed-in player. Staff add and remove people through the routes under
|
|
* `/api/staff/studio-access`, behind the same `requireStaff` gate as moderation.
|
|
* The grant is the account's `hasStudio` flag; `auth` stamps `betastudio` from it
|
|
* on the next Studio sign-in or refresh. Who granted what is on `audit_log`. A Discord
|
|
* role in the `STUDIO_ROLLOUT` var grants the same access without the flag
|
|
* (`hasStudioAccess`); the status below reports it, the staff list does not — that list
|
|
* is the accounts staff added by hand.
|
|
*/
|
|
|
|
/** Whether the signed-in player can upload — flag or rollout role. 401 with no session. */
|
|
export async function studioAccessStatusHandler(c: Context<App>) {
|
|
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
|
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
|
|
const account = await getAccount(c.env.DB, accountId)
|
|
const flags = await avatarStudioFlags(c.env.DB, accountId)
|
|
return c.json({
|
|
...Object.fromEntries(
|
|
Object.entries(AVATAR_STUDIO_ACCESS).map(([kind, mask]) => [kind, (flags & mask) === mask])
|
|
),
|
|
granted: await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT),
|
|
avatar: (flags & 7) !== 0,
|
|
screenshare: account?.hasScreenshare === true,
|
|
})
|
|
}
|
|
|
|
type AccessKind = 'studio' | 'avatar' | 'screenshare' | keyof typeof AVATAR_STUDIO_ACCESS
|
|
function accessKind(value: unknown): AccessKind | null {
|
|
if (value === undefined) return 'studio'
|
|
if (value === 'studio' || value === 'avatar' || value === 'screenshare') return value
|
|
return typeof value === 'string' && Object.hasOwn(AVATAR_STUDIO_ACCESS, value)
|
|
? (value as AccessKind)
|
|
: null
|
|
}
|
|
function avatarMask(kind: AccessKind): number {
|
|
return kind === 'avatar'
|
|
? 7
|
|
: Object.hasOwn(AVATAR_STUDIO_ACCESS, kind)
|
|
? AVATAR_STUDIO_ACCESS[kind as keyof typeof AVATAR_STUDIO_ACCESS]
|
|
: 0
|
|
}
|
|
|
|
/** Every account with the flag set. */
|
|
export async function listStudioAccessHandler(c: Context<App>) {
|
|
const kind = accessKind(c.req.query('kind'))
|
|
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
|
|
const accounts = await listStudioAccounts(c.env.DB, kind)
|
|
return c.json({
|
|
accounts: accounts.map((account) => ({
|
|
accountId: account.accountId,
|
|
username: account.username,
|
|
displayName: account.displayName,
|
|
enabled: kind !== 'screenshare' || account.hasScreenshare === true,
|
|
})),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Add a player, by username or account id. Unknown players are a 404 rather than
|
|
* a flag on an account that doesn't exist. A player who already has it keeps it,
|
|
* and the reply says so.
|
|
*/
|
|
export async function grantStudioAccessHandler(c: Context<App>) {
|
|
const body = await c.req.json().catch(() => null)
|
|
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
return c.json({ error: 'Expected a JSON body' }, 400)
|
|
}
|
|
const posted = body as { username?: unknown; accountId?: unknown; kind?: unknown }
|
|
const kind = accessKind(posted.kind)
|
|
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
|
|
const username =
|
|
typeof posted.username === 'string' ? posted.username.trim().replace(/^@/, '') : ''
|
|
const accountId = postedAccountId(posted.accountId)
|
|
|
|
let account: Account | null = null
|
|
if (username !== '') {
|
|
account = await getAccountByUsername(c.env.DB, username)
|
|
if (!account) return c.json({ error: `No player is called @${username}` }, 404)
|
|
if (accountId !== null && accountId !== account.accountId) {
|
|
return c.json({ error: 'That username and account id are different players' }, 400)
|
|
}
|
|
} else if (accountId !== null) {
|
|
account = await getAccount(c.env.DB, accountId)
|
|
if (!account) return c.json({ error: 'No player has that account id' }, 404)
|
|
} else {
|
|
return c.json({ error: 'Name a player by username or account id' }, 400)
|
|
}
|
|
|
|
const alreadyGranted =
|
|
avatarMask(kind) !== 0
|
|
? ((await avatarStudioFlags(c.env.DB, account.accountId)) & avatarMask(kind)) ===
|
|
avatarMask(kind)
|
|
: kind === 'screenshare'
|
|
? account.hasScreenshare === true
|
|
: account.hasStudio === true
|
|
await setAccess(c, account.accountId, kind, true)
|
|
await recordAudit(c, `grant_${kind}_access`, {
|
|
playerId: account.accountId,
|
|
username: account.username,
|
|
alreadyGranted,
|
|
})
|
|
return c.json({
|
|
accountId: account.accountId,
|
|
username: account.username,
|
|
granted: true,
|
|
alreadyGranted,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Take a player off the list. A player who did not have it answers 200 with
|
|
* `removed: false`, so a double click is not an error. An id that names no
|
|
* account is a 404.
|
|
*/
|
|
export async function revokeStudioAccessHandler(c: Context<App>) {
|
|
const kind = accessKind(c.req.query('kind'))
|
|
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
|
|
const accountId = Number(c.req.param('id'))
|
|
if (!Number.isInteger(accountId) || accountId <= 0) {
|
|
return c.json({ error: 'A numeric player id is required' }, 400)
|
|
}
|
|
const account = await getAccount(c.env.DB, accountId)
|
|
if (!account) return c.json({ error: 'No player has that account id' }, 404)
|
|
const removed =
|
|
avatarMask(kind) !== 0
|
|
? ((await avatarStudioFlags(c.env.DB, accountId)) & avatarMask(kind)) !== 0
|
|
: kind === 'screenshare'
|
|
? account.hasScreenshare === true
|
|
: account.hasStudio === true
|
|
await setAccess(c, accountId, kind, false)
|
|
await recordAudit(c, `revoke_${kind}_access`, {
|
|
playerId: accountId,
|
|
username: account.username,
|
|
removed,
|
|
})
|
|
return c.json({ accountId, granted: false, removed })
|
|
}
|
|
|
|
async function setAccess(c: Context<App>, accountId: number, kind: AccessKind, enabled: boolean) {
|
|
const mask = avatarMask(kind)
|
|
if (mask !== 0) {
|
|
const now = new Date().toISOString()
|
|
await c.env.DB.prepare(
|
|
`INSERT INTO avatar_studio_access (account_id, flags, created_at, modified_at)
|
|
VALUES (?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET
|
|
flags = ${enabled ? 'flags | ?' : 'flags & ~?'}, modified_at = excluded.modified_at`
|
|
)
|
|
.bind(accountId, enabled ? mask : 0, now, now, mask)
|
|
.run()
|
|
} else {
|
|
await updateAccount(
|
|
c.env.DB,
|
|
accountId,
|
|
kind === 'screenshare' ? { hasScreenshare: enabled } : { hasStudio: enabled }
|
|
)
|
|
}
|
|
}
|
|
|
|
/** A positive integer account id from a JSON field, or null when it isn't one. */
|
|
function postedAccountId(value: unknown): number | null {
|
|
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value
|
|
if (typeof value === 'string' && /^\d+$/.test(value)) {
|
|
const parsed = Number.parseInt(value, 10)
|
|
return parsed > 0 ? parsed : null
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Record a change on `audit_log`. Written after the change has committed and
|
|
* never throws: the flag has already changed, and a failed insert must not tell
|
|
* the moderator it didn't.
|
|
*/
|
|
async function recordAudit(
|
|
c: Context<App>,
|
|
action: string,
|
|
data: Record<string, unknown>
|
|
): Promise<void> {
|
|
try {
|
|
await writeAuditLog(c.env.DB, { playerId: c.get('staffId'), action, data })
|
|
} catch (err) {
|
|
logger.error('could not write an audit log row', {
|
|
action,
|
|
moderatorId: c.get('staffId'),
|
|
error: err instanceof Error ? err.message : String(err),
|
|
})
|
|
}
|
|
}
|