mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[link] store share links
This commit is contained in:
Vendored
+6
@@ -10,3 +10,9 @@ declare namespace Cloudflare {
|
||||
mainModule: MainModule
|
||||
}
|
||||
}
|
||||
|
||||
// Tests read the migration as a string and apply it, so the schema has one source.
|
||||
declare module '*.sql?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Share links (`POST /actionlink`): a short code standing for something in the game — a
|
||||
-- room, a club, an event — that a player can hand to someone else.
|
||||
--
|
||||
-- Each row is what the client sent, kept as sent:
|
||||
-- code_type the link's kind, the `codeType` form field (3 club · 4 event · 5 room seen)
|
||||
-- extra_data_id the id of the thing linked to, the `extraDataId` field (RoomId, ClubId,
|
||||
-- PlayerEventId); NULL when the client sends none
|
||||
-- data the `data` field verbatim: a JSON string `{ ExtraJson, Platform, Type }`
|
||||
-- whose ExtraJson is itself an escaped JSON string. Stored opaque; nothing
|
||||
-- here parses it
|
||||
-- valid_hours the `validHours` field; `expires_at` is created_at plus that many hours
|
||||
--
|
||||
-- `code` is the six-character code answered to the client, unique across every link.
|
||||
-- A new code per request: asking twice for the same room gives two codes.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS link (
|
||||
code TEXT PRIMARY KEY,
|
||||
creator_account_id INTEGER NOT NULL,
|
||||
code_type INTEGER NOT NULL,
|
||||
extra_data_id INTEGER,
|
||||
data TEXT,
|
||||
valid_hours INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_link_target ON link (code_type, extra_data_id);
|
||||
@@ -12,10 +12,12 @@
|
||||
"deploy": "run-wrangler-deploy",
|
||||
"dev": "run-wrangler-dev",
|
||||
"fix:workers-types": "run-wrangler-types",
|
||||
"migrate": "run-wrangler-migrate",
|
||||
"test": "run-vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"hono": "4.13.5",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ export type Env = SharedHonoEnv & {
|
||||
* `auth` verify here.
|
||||
*/
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
/** Shared `recflare` D1 — this worker owns the `link` table. */
|
||||
DB: D1Database
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The `link` table (migrations/0001_link.sql): one row per share code minted by
|
||||
* `POST /actionlink`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The code alphabet: upper-case letters and digits without the look-alikes (0/O, 1/I/L),
|
||||
* since a player may read a code aloud or type it from a screenshot. 31 symbols, so six of
|
||||
* them give ~887 million codes.
|
||||
*/
|
||||
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
|
||||
export const CODE_LENGTH = 6
|
||||
|
||||
/** How many fresh codes to try before giving up on a run of collisions. */
|
||||
const MAX_ATTEMPTS = 8
|
||||
|
||||
/** A random code. Rejection-sampled so every symbol is equally likely. */
|
||||
export function randomCode(): string {
|
||||
// The largest multiple of the alphabet size that fits in a byte.
|
||||
const limit = 256 - (256 % CODE_ALPHABET.length)
|
||||
let code = ''
|
||||
while (code.length < CODE_LENGTH) {
|
||||
for (const byte of crypto.getRandomValues(new Uint8Array(CODE_LENGTH * 2))) {
|
||||
if (byte >= limit) continue
|
||||
code += CODE_ALPHABET[byte % CODE_ALPHABET.length]
|
||||
if (code.length === CODE_LENGTH) break
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
export interface NewLink {
|
||||
creatorAccountId: number
|
||||
codeType: number
|
||||
extraDataId: number | null
|
||||
data: string | null
|
||||
validHours: number
|
||||
}
|
||||
|
||||
export interface LinkRow {
|
||||
code: string
|
||||
creator_account_id: number
|
||||
code_type: number
|
||||
extra_data_id: number | null
|
||||
data: string | null
|
||||
valid_hours: number
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a link under a fresh code and return the code. A code already taken is skipped
|
||||
* (`ON CONFLICT DO NOTHING` returns no row) and another drawn.
|
||||
*/
|
||||
export async function createLink(db: D1Database, link: NewLink, now = new Date()): Promise<string> {
|
||||
const createdAt = now.toISOString()
|
||||
const expiresAt = new Date(now.getTime() + link.validHours * 3_600_000).toISOString()
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
const code = randomCode()
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO link (code, creator_account_id, code_type, extra_data_id, data, valid_hours, created_at, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
RETURNING code`
|
||||
)
|
||||
.bind(
|
||||
code,
|
||||
link.creatorAccountId,
|
||||
link.codeType,
|
||||
link.extraDataId,
|
||||
link.data,
|
||||
link.validHours,
|
||||
createdAt,
|
||||
expiresAt
|
||||
)
|
||||
.first<{ code: string }>()
|
||||
if (row) return row.code
|
||||
}
|
||||
throw new Error(`no free link code after ${MAX_ATTEMPTS} attempts`)
|
||||
}
|
||||
|
||||
/** One link by code, or null. */
|
||||
export async function getLink(db: D1Database, code: string): Promise<LinkRow | null> {
|
||||
return db.prepare('SELECT * FROM link WHERE code = ?1').bind(code).first<LinkRow>()
|
||||
}
|
||||
@@ -2,9 +2,26 @@ import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import { createLink } from './link-db'
|
||||
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
* The longest a link may live, in hours: ten years. The client asks for at most a year
|
||||
* (8760, a room link); this only stops a hand-written request from minting a date that
|
||||
* doesn't exist.
|
||||
*/
|
||||
const MAX_VALID_HOURS = 87_600
|
||||
|
||||
/** A form field as a whole integer, or null when it's absent or anything else. */
|
||||
function intField(value: unknown): number | null {
|
||||
if (typeof value !== 'string' || !/^-?\d+$/.test(value.trim())) return null
|
||||
const n = Number(value.trim())
|
||||
return Number.isSafeInteger(n) ? n : null
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -23,4 +40,33 @@ const app = new Hono<App>()
|
||||
return c.text('hello, world!')
|
||||
})
|
||||
|
||||
// Mint a share code for a room, club or event — the client's "share" link. A form body:
|
||||
// data '{"ExtraJson":"{\"RoomId\":1162}","Platform":0,"Type":5}', stored verbatim
|
||||
// validHours how long the code lives (8760 room · 2160 club · 8640 event seen)
|
||||
// codeType the link's kind (5 room · 3 club · 4 event seen)
|
||||
// extraDataId the id of the thing linked to
|
||||
// Answers the code as a bare JSON string (`"ABC123"`), nothing else. Auth-gated: the
|
||||
// row records who made it.
|
||||
.post('/actionlink', async (c) => {
|
||||
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const codeType = intField(body.codeType)
|
||||
const validHours = intField(body.validHours)
|
||||
if (codeType === null) return c.json({ error: 'codeType is required' }, 400)
|
||||
if (validHours === null || validHours <= 0 || validHours > MAX_VALID_HOURS) {
|
||||
return c.json({ error: 'validHours must be a positive number of hours' }, 400)
|
||||
}
|
||||
|
||||
const code = await createLink(c.env.DB, {
|
||||
creatorAccountId: accountId,
|
||||
codeType,
|
||||
extraDataId: intField(body.extraDataId),
|
||||
data: typeof body.data === 'string' ? body.data : null,
|
||||
validHours,
|
||||
})
|
||||
return c.json(code)
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -1,8 +1,139 @@
|
||||
import { SELF } from 'cloudflare:test'
|
||||
import { expect, it } from 'vitest'
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import linkSql from '../../../migrations/0001_link.sql?raw'
|
||||
import { getLink, randomCode } from '../../link-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded
|
||||
// into the JWT_SECRET store.
|
||||
const TEST_SECRET = 'test-signing-key'
|
||||
|
||||
function b64url(input: ArrayBuffer | string): string {
|
||||
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
async function bearer(sub: number): Promise<Record<string, string>> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||
JSON.stringify({ sub: String(sub), exp: now + 3600 })
|
||||
)}`
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(TEST_SECRET),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
|
||||
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
|
||||
// Apply the migration itself, statement by statement, comments stripped.
|
||||
const statements = linkSql
|
||||
.replace(/--.*$/gm, '')
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
for (const stmt of statements) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
async function actionlink(caller: number | null, fields: Record<string, string>) {
|
||||
return SELF.fetch(`${ORIGIN}/actionlink`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(caller === null ? {} : await bearer(caller)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
}
|
||||
|
||||
it('response with hello world', async () => {
|
||||
const res = await SELF.fetch('https://example.com')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`)
|
||||
})
|
||||
|
||||
describe('POST /actionlink', () => {
|
||||
// The three bodies the client sends, verbatim.
|
||||
const ROOM = {
|
||||
data: '{"ExtraJson":"{\\u0022RoomId\\u0022:1162}","Platform":0,"Type":5}',
|
||||
validHours: '8760',
|
||||
codeType: '5',
|
||||
extraDataId: '1162',
|
||||
}
|
||||
const CLUB = {
|
||||
data: '{"ExtraJson":"{\\u0022ClubId\\u0022:4,\\u0022Joinability\\u0022:0,\\u0022Visibility\\u0022:1}","Platform":0,"Type":3}',
|
||||
validHours: '2160',
|
||||
codeType: '3',
|
||||
extraDataId: '4',
|
||||
}
|
||||
const EVENT = {
|
||||
data: '{"ExtraJson":"{\\u0022PlayerEventId\\u0022:174}","Platform":0,"Type":4}',
|
||||
validHours: '8640',
|
||||
codeType: '4',
|
||||
extraDataId: '174',
|
||||
}
|
||||
|
||||
it('answers a bare JSON string code and stores what the client sent', async () => {
|
||||
for (const body of [ROOM, CLUB, EVENT]) {
|
||||
const before = Date.now()
|
||||
const res = await actionlink(950001, body)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toContain('application/json')
|
||||
const text = await res.text()
|
||||
const code = JSON.parse(text) as unknown
|
||||
expect(typeof code).toBe('string')
|
||||
expect(text).toBe(JSON.stringify(code))
|
||||
expect(code).toMatch(/^[A-Z2-9]{6}$/)
|
||||
|
||||
const row = await getLink(env.DB, code as string)
|
||||
expect(row).toMatchObject({
|
||||
creator_account_id: 950001,
|
||||
code_type: Number(body.codeType),
|
||||
extra_data_id: Number(body.extraDataId),
|
||||
data: body.data,
|
||||
valid_hours: Number(body.validHours),
|
||||
})
|
||||
const lived = Date.parse(row!.expires_at) - Date.parse(row!.created_at)
|
||||
expect(lived).toBe(Number(body.validHours) * 3_600_000)
|
||||
expect(Date.parse(row!.created_at)).toBeGreaterThanOrEqual(before - 1000)
|
||||
}
|
||||
})
|
||||
|
||||
it('mints a new code each time', async () => {
|
||||
const a = (await (await actionlink(950002, ROOM)).json()) as string
|
||||
const b = (await (await actionlink(950002, ROOM)).json()) as string
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
expect((await actionlink(null, ROOM)).status).toBe(401)
|
||||
})
|
||||
|
||||
it('400s without a codeType or a usable validHours', async () => {
|
||||
const { codeType: _codeType, ...noType } = ROOM
|
||||
expect((await actionlink(950003, noType)).status).toBe(400)
|
||||
expect((await actionlink(950003, { ...ROOM, validHours: '0' })).status).toBe(400)
|
||||
expect((await actionlink(950003, { ...ROOM, validHours: 'soon' })).status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('randomCode', () => {
|
||||
it('draws six symbols with no look-alikes', () => {
|
||||
for (let i = 0; i < 200; i++) expect(randomCode()).toMatch(/^[A-HJKMNP-Z2-9]{6}$/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
"main": "src/link.app.ts",
|
||||
"compatibility_date": "2026-06-16",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "recflare",
|
||||
"database_id": "local",
|
||||
"migrations_dir": "migrations",
|
||||
"migrations_table": "d1_migrations_link"
|
||||
}
|
||||
],
|
||||
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
||||
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
||||
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
|
||||
|
||||
Reference in New Issue
Block a user