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
|
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",
|
"deploy": "run-wrangler-deploy",
|
||||||
"dev": "run-wrangler-dev",
|
"dev": "run-wrangler-dev",
|
||||||
"fix:workers-types": "run-wrangler-types",
|
"fix:workers-types": "run-wrangler-types",
|
||||||
|
"migrate": "run-wrangler-migrate",
|
||||||
"test": "run-vitest"
|
"test": "run-vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
|
"@repo/jwt": "workspace:*",
|
||||||
"hono": "4.13.5",
|
"hono": "4.13.5",
|
||||||
"workers-tagged-logger": "1.0.1"
|
"workers-tagged-logger": "1.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type Env = SharedHonoEnv & {
|
|||||||
* `auth` verify here.
|
* `auth` verify here.
|
||||||
*/
|
*/
|
||||||
JWT_SECRET: SecretsStoreSecret
|
JWT_SECRET: SecretsStoreSecret
|
||||||
|
/** Shared `recflare` D1 — this worker owns the `link` table. */
|
||||||
|
DB: D1Database
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Variables can be extended */
|
/** 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 { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
|
import { createLink } from './link-db'
|
||||||
|
|
||||||
import type { App } from './context'
|
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>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -23,4 +40,33 @@ const app = new Hono<App>()
|
|||||||
return c.text('hello, world!')
|
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
|
export default app
|
||||||
|
|||||||
@@ -1,8 +1,139 @@
|
|||||||
import { SELF } from 'cloudflare:test'
|
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||||
import { expect, it } from 'vitest'
|
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 () => {
|
it('response with hello world', async () => {
|
||||||
const res = await SELF.fetch('https://example.com')
|
const res = await SELF.fetch('https://example.com')
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`)
|
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",
|
"main": "src/link.app.ts",
|
||||||
"compatibility_date": "2026-06-16",
|
"compatibility_date": "2026-06-16",
|
||||||
"compatibility_flags": ["nodejs_compat"],
|
"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
|
// 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"
|
// 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.
|
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
|
||||||
|
|||||||
Generated
+3
@@ -736,6 +736,9 @@ importers:
|
|||||||
'@repo/hono-helpers':
|
'@repo/hono-helpers':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/hono-helpers
|
version: link:../../packages/hono-helpers
|
||||||
|
'@repo/jwt':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/jwt
|
||||||
hono:
|
hono:
|
||||||
specifier: 4.13.5
|
specifier: 4.13.5
|
||||||
version: 4.13.5
|
version: 4.13.5
|
||||||
|
|||||||
Reference in New Issue
Block a user