functions/api/scores.js 3.3 K raw
1
import { GAME_CONFIG, FRAME_RATE } from '../../src/config.js'
2
import { verifyToken } from './_hmac.js'
3
4
function jsonResponse(data, status = 200) {
5
  return new Response(JSON.stringify(data), {
6
    status,
7
    headers: { 'Content-Type': 'application/json' },
8
  })
9
}
10
11
function errorResponse(message, status = 400) {
12
  return jsonResponse({ error: message }, status)
13
}
14
15
export const onRequestGet = async ({ env }) => {
16
  try {
17
    const results = await env.DB
18
      .prepare('SELECT player_name, score FROM scores ORDER BY score DESC LIMIT 10')
19
      .all()
20
21
    return jsonResponse({ scores: results.results })
22
  } catch (err) {
23
    console.error('Failed to fetch scores:', err)
24
    return errorResponse('Failed to fetch scores', 500)
25
  }
26
}
27
28
export const onRequestPost = async ({ request, env, waitUntil }) => {
29
  let body
30
  try {
31
    body = await request.json()
32
  } catch {
33
    return errorResponse('Invalid JSON')
34
  }
35
36
  const { playerName, score, token } = body
37
38
  if (
39
    typeof playerName !== 'string' ||
40
    typeof score !== 'number' ||
41
    typeof token !== 'string'
42
  ) {
43
    return errorResponse('Invalid request body')
44
  }
45
46
  // Name validation
47
  if (playerName.length > GAME_CONFIG.MAX_PLAYER_NAME_LENGTH || playerName.length === 0) {
48
    return errorResponse('Invalid player name')
49
  }
50
51
  // Score bounds
52
  if (score < 0 || score > GAME_CONFIG.MAX_SCORE || !Number.isInteger(score)) {
53
    return errorResponse('Invalid score')
54
  }
55
56
  // Verify token signature
57
  const payload = await verifyToken(token, env.HMAC_SECRET)
58
  if (!payload) {
59
    return errorResponse('Invalid session token')
60
  }
61
62
  const now = Date.now()
63
  const elapsed = now - payload.issuedAt
64
65
  // Session age check
66
  if (elapsed > GAME_CONFIG.MAX_SESSION_AGE_MS || elapsed < 0) {
67
    return errorResponse('Session expired')
68
  }
69
70
  // Minimum game duration: score requires at least this many ms
71
  const minDuration = Math.max(
72
    GAME_CONFIG.MIN_GAME_DURATION_MS,
73
    score * GAME_CONFIG.SCORE_INCREASE_RATE * (1000 / FRAME_RATE) * GAME_CONFIG.WALL_CLOCK_TOLERANCE
74
  )
75
  if (elapsed < minDuration) {
76
    return errorResponse('Score validation failed')
77
  }
78
79
  // Replay prevention
80
  try {
81
    const existing = await env.DB
82
      .prepare('SELECT nonce FROM used_tokens WHERE nonce = ?')
83
      .bind(payload.nonce)
84
      .first()
85
86
    if (existing) {
87
      return errorResponse('Session already used')
88
    }
89
90
    await env.DB
91
      .prepare('INSERT INTO used_tokens (nonce) VALUES (?)')
92
      .bind(payload.nonce)
93
      .run()
94
  } catch (err) {
95
    console.error('Token check error:', err)
96
    return errorResponse('Database error', 500)
97
  }
98
99
  // Insert score
100
  try {
101
    const durationMs = elapsed
102
    const endFrame = score * GAME_CONFIG.SCORE_INCREASE_RATE
103
104
    await env.DB
105
      .prepare(
106
        'INSERT INTO scores (player_name, score, end_frame, duration_ms) VALUES (?, ?, ?, ?)'
107
      )
108
      .bind(
109
        playerName.slice(0, GAME_CONFIG.MAX_PLAYER_NAME_LENGTH),
110
        score,
111
        endFrame,
112
        durationMs
113
      )
114
      .run()
115
116
    // Clean up old used tokens in the background
117
    waitUntil(
118
      env.DB
119
        .prepare("DELETE FROM used_tokens WHERE used_at < datetime('now', '-1 day')")
120
        .run()
121
        .catch(() => {})
122
    )
123
124
    return jsonResponse({ ok: true, score })
125
  } catch (err) {
126
    console.error('Database error:', err)
127
    return errorResponse('Database error', 500)
128
  }
129
}