security: fix K1-K3 critical + H1/H2/H5 + M1/M4/M5/M6/N2

K1: Asset-Token via Authorization-Header (nicht URL/Query → nicht in Logs)
    + UUID-Format-Whitelist gegen Path-Traversal / SSRF
K2: /profile erfordert kurzlebiges Registration-Token (10 Min, signiert)
    statt ungeprüfter userId aus dem Body
K3: PATCH /pair/:pairId/points prüft Ownership via Directus bevor Update
H1: In-Memory Rate Limiting (Login/Register: 10/15min, Assets: 60/min)
H2: Server startet nicht ohne CORS_ORIGIN (kein '*'-Fallback)
H5: lang-Parameter Whitelist in content + UUID-Validierung in progress
M1: points_earned, card_type, result server-seitig validiert (0-100, Enums)
M4: Authorization-Header in Logs geschwärzt
M5: Passwort-Länge server-seitig geprüft
M6: Startup-Check für alle kritischen Env-Vars
N2: pairId-UUID-Format erzwungen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:55:23 +02:00
parent 593753fa4d
commit 1e70ab15e9
6 changed files with 269 additions and 178 deletions

View File

@@ -6,18 +6,55 @@ import { logger } from 'hono/logger'
import authRoutes from './routes/auth' import authRoutes from './routes/auth'
import contentRoutes from './routes/content' import contentRoutes from './routes/content'
import progressRoutes from './routes/progress' import progressRoutes from './routes/progress'
import { rateLimit } from './lib/rateLimit'
// ── Startup: kritische Env-Vars prüfen ───────────────────────────────────────
const REQUIRED_ENV = ['JWT_SECRET', 'DIRECTUS_URL', 'DIRECTUS_ADMIN_TOKEN', 'CORS_ORIGIN']
for (const key of REQUIRED_ENV) {
if (!process.env[key]) {
console.error(`FATAL: Fehlende Umgebungsvariable: ${key}`)
process.exit(1)
}
}
const app = new Hono() const app = new Hono()
app.use('*', logger()) // ── Logger (Authorization-Header geschwärzt) ─────────────────────────────────
app.use('*', cors({ app.use('*', logger((str, ...rest) => {
origin: process.env.CORS_ORIGIN || '*', const sanitized = str.replace(/Authorization: Bearer [^\s"]+/gi, 'Authorization: Bearer [REDACTED]')
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], console.log(sanitized, ...rest)
allowHeaders: ['Content-Type', 'Authorization'],
})) }))
// ── CORS (kein unsicherer Fallback) ──────────────────────────────────────────
app.use('*', cors({
origin: process.env.CORS_ORIGIN!,
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'X-Registration-Token'],
credentials: false,
}))
// ── Security-Header ───────────────────────────────────────────────────────────
app.use('*', async (c, next) => {
await next()
c.header('X-Content-Type-Options', 'nosniff')
c.header('X-Frame-Options', 'DENY')
c.header('Referrer-Policy', 'no-referrer')
})
// ── Rate Limits ───────────────────────────────────────────────────────────────
// Auth-Endpoints: 10 Requests / 15 Minuten
app.use('/languparent/auth/login', rateLimit(10, 15 * 60 * 1000))
app.use('/languparent/auth/register', rateLimit(10, 15 * 60 * 1000))
app.use('/languparent/auth/profile', rateLimit(10, 15 * 60 * 1000))
// Username-Check: 30 Requests / Minute
app.use('/languparent/auth/check-username', rateLimit(30, 60 * 1000))
// Assets: 60 / Minute (Bilder für Learning-Cards)
app.use('/languparent/assets/*', rateLimit(60, 60 * 1000))
// ── Health ────────────────────────────────────────────────────────────────────
app.get('/health', (c) => c.json({ ok: true, service: 'hejyou-api' })) app.get('/health', (c) => c.json({ ok: true, service: 'hejyou-api' }))
// ── Routes ────────────────────────────────────────────────────────────────────
app.route('/languparent/auth', authRoutes) app.route('/languparent/auth', authRoutes)
app.route('/languparent', contentRoutes) app.route('/languparent', contentRoutes)
app.route('/languparent', progressRoutes) app.route('/languparent', progressRoutes)

View File

@@ -140,13 +140,31 @@ export async function dGetActivePair(profileId: string): Promise<any> {
return data.data[0] || null return data.data[0] || null
} }
export async function dUpdatePairPoints(pairId: string, points: number): Promise<boolean> { // K-3 Fix: Ownership vor Update prüfen
export async function dUpdatePairPoints(
pairId: string,
points: number,
ownerProfileId: string,
): Promise<'ok' | 'not_found' | 'forbidden'> {
// 1. Ownership prüfen
const checkRes = await fetch(
`${BASE}/items/learning_pairs/${pairId}?fields=user`,
{ headers: adminHeaders() },
)
if (!checkRes.ok) return 'not_found'
const checkData = await checkRes.json() as { data: { user: string } | null }
if (!checkData.data) return 'not_found'
if (checkData.data.user !== ownerProfileId) return 'forbidden'
// 2. Wert in sinnvollem Bereich halten
const clamped = Math.max(0, Math.min(points, 10_000_000))
const res = await fetch(`${BASE}/items/learning_pairs/${pairId}`, { const res = await fetch(`${BASE}/items/learning_pairs/${pairId}`, {
method: 'PATCH', method: 'PATCH',
headers: adminHeaders(), headers: adminHeaders(),
body: JSON.stringify({ points }), body: JSON.stringify({ points: clamped }),
}) })
return res.ok return res.ok ? 'ok' : 'not_found'
} }
// ── Words ───────────────────────────────────────────────────────────────────── // ── Words ─────────────────────────────────────────────────────────────────────
@@ -290,10 +308,18 @@ export async function dGetQAPairsAtLevel(level: number, langSuffix = 'de'): Prom
// ── Assets ──────────────────────────────────────────────────────────────────── // ── Assets ────────────────────────────────────────────────────────────────────
export function dAssetUrl(fileId: string): string { const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
return `${BASE}/assets/${fileId}?access_token=${encodeURIComponent(ADMIN_TOKEN)}`
// K-1 Fix: Token via Authorization-Header (nicht Query-Param → taucht nicht in Logs auf)
export async function fetchAsset(fileId: string): Promise<Response> {
if (!UUID_RE.test(fileId)) throw new Error('Invalid fileId format')
return fetch(`${BASE}/assets/${fileId}`, {
headers: { 'Authorization': `Bearer ${ADMIN_TOKEN}` },
})
} }
export { UUID_RE }
// ── Profile Data ────────────────────────────────────────────────────────────── // ── Profile Data ──────────────────────────────────────────────────────────────
export async function dGetProfileData(userId: string): Promise<any> { export async function dGetProfileData(userId: string): Promise<any> {

37
src/lib/rateLimit.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { Context, Next } from 'hono'
interface Entry { count: number; resetAt: number }
const store = new Map<string, Entry>()
// Stale entries bereinigen
setInterval(() => {
const now = Date.now()
for (const [k, e] of store) if (now > e.resetAt) store.delete(k)
}, 10 * 60 * 1000)
function clientIp(c: Context): string {
return (
c.req.header('CF-Connecting-IP') ||
c.req.header('X-Forwarded-For')?.split(',')[0].trim() ||
'unknown'
)
}
export function rateLimit(maxRequests: number, windowMs: number) {
return async (c: Context, next: Next) => {
const key = `${clientIp(c)}:${c.req.path}`
const now = Date.now()
const entry = store.get(key)
if (!entry || now > entry.resetAt) {
store.set(key, { count: 1, resetAt: now + windowMs })
return next()
}
if (entry.count >= maxRequests) {
c.header('Retry-After', String(Math.ceil((entry.resetAt - now) / 1000)))
return c.json({ error: 'Too many requests. Please try again later.' }, 429)
}
entry.count++
return next()
}
}

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { sign } from 'hono/jwt' import { sign, verify } from 'hono/jwt'
import { import {
dLogin, dLogin,
dRegister, dRegister,
@@ -11,54 +11,72 @@ import {
import { requireAuth } from '../middleware/auth' import { requireAuth } from '../middleware/auth'
import type { JwtPayload } from '../middleware/auth' import type { JwtPayload } from '../middleware/auth'
const auth = new Hono() const JWT_SECRET = () => process.env.JWT_SECRET!
const JWT_EXPIRY_SECONDS = 60 * 60 * 24 * 30 // 30 Tage
const REG_TOKEN_SECONDS = 60 * 10 // 10 Minuten
const JWT_EXPIRY_SECONDS = 60 * 60 * 24 * 30 // 30 days async function issueJwt(sub: string, username: string) {
async function issueJwt(sub: string, username: string): Promise<{ token: string; expiresIn: number }> {
const secret = process.env.JWT_SECRET!
const exp = Math.floor(Date.now() / 1000) + JWT_EXPIRY_SECONDS const exp = Math.floor(Date.now() / 1000) + JWT_EXPIRY_SECONDS
const payload: JwtPayload = { sub, username, exp } const token = await sign({ sub, username, exp } as JwtPayload, JWT_SECRET())
const token = await sign(payload, secret)
return { token, expiresIn: JWT_EXPIRY_SECONDS } return { token, expiresIn: JWT_EXPIRY_SECONDS }
} }
// POST /register const auth = new Hono()
// ── POST /register ────────────────────────────────────────────────────────────
auth.post('/register', async (c) => { auth.post('/register', async (c) => {
let body: { email?: string; password?: string } let body: { email?: string; password?: string }
try { try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
const { email, password } = body const { email, password } = body
if (!email || !password) { if (!email || !password) return c.json({ error: 'email und password erforderlich' }, 400)
return c.json({ error: 'email and password are required' }, 400) // M-5: Passwort-Validierung server-seitig
} if (password.length < 8) return c.json({ error: 'Passwort muss mindestens 8 Zeichen haben' }, 400)
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: 'Ungültige E-Mail-Adresse' }, 400)
try { try {
await dRegister(email, password) await dRegister(email, password)
const { access_token } = await dLogin(email, password) const { access_token } = await dLogin(email, password)
const user = await dGetUserByToken(access_token) const user = await dGetUserByToken(access_token)
return c.json({ userId: user.id }, 201)
// K-2: Kurzlebiges Registration-Token (10 Min) statt userId im Klartext
const exp = Math.floor(Date.now() / 1000) + REG_TOKEN_SECONDS
const registrationToken = await sign(
{ sub: user.id, purpose: 'profile_setup', exp },
JWT_SECRET(),
)
return c.json({ registrationToken }, 201)
} catch (err: any) { } catch (err: any) {
return c.json({ error: err.message || 'Registration failed' }, 400) return c.json({ error: err.message || 'Registrierung fehlgeschlagen' }, 400)
} }
}) })
// POST /profile // ── POST /profile ─────────────────────────────────────────────────────────────
// K-2: userId kommt aus dem signierten Registration-Token (nicht aus dem Body)
auth.post('/profile', async (c) => { auth.post('/profile', async (c) => {
let body: { userId?: string; username?: string; nativeLang?: string; targetLang?: string } const regTokenHeader = c.req.header('X-Registration-Token')
try { if (!regTokenHeader) return c.json({ error: 'X-Registration-Token fehlt' }, 401)
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
const { userId, username, nativeLang, targetLang } = body let regPayload: any
if (!userId || !username || !nativeLang || !targetLang) { try {
return c.json({ error: 'userId, username, nativeLang, and targetLang are required' }, 400) regPayload = await verify(regTokenHeader, JWT_SECRET(), 'HS256')
} catch {
return c.json({ error: 'Ungültiges oder abgelaufenes Registration-Token' }, 401)
}
if (regPayload.purpose !== 'profile_setup') {
return c.json({ error: 'Falsches Token-Purpose' }, 401)
}
const userId = regPayload.sub as string
let body: { username?: string; nativeLang?: string; targetLang?: string }
try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
const { username, nativeLang, targetLang } = body
if (!username || !nativeLang || !targetLang) {
return c.json({ error: 'username, nativeLang und targetLang erforderlich' }, 400)
}
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
return c.json({ error: 'Username: 320 Zeichen, nur Buchstaben, Zahlen und _' }, 400)
} }
try { try {
@@ -66,69 +84,57 @@ auth.post('/profile', async (c) => {
const { token, expiresIn } = await issueJwt(userId, profileId) const { token, expiresIn } = await issueJwt(userId, profileId)
return c.json({ token, expiresIn }, 201) return c.json({ token, expiresIn }, 201)
} catch (err: any) { } catch (err: any) {
return c.json({ error: err.message || 'Profile creation failed' }, 400) return c.json({ error: err.message || 'Profilerstellung fehlgeschlagen' }, 400)
} }
}) })
// POST /login // ── POST /login ───────────────────────────────────────────────────────────────
auth.post('/login', async (c) => { auth.post('/login', async (c) => {
let body: { email?: string; password?: string } let body: { email?: string; password?: string }
try { try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
const { email, password } = body const { email, password } = body
if (!email || !password) { if (!email || !password) return c.json({ error: 'email und password erforderlich' }, 400)
return c.json({ error: 'email and password are required' }, 400)
}
try { try {
const { access_token } = await dLogin(email, password) const { access_token } = await dLogin(email, password)
const user = await dGetUserByToken(access_token) const user = await dGetUserByToken(access_token)
if (user.username === null) { if (!user.username) {
return c.json( return c.json({ error: 'Profil noch nicht eingerichtet', needsProfile: true, userId: user.id }, 403)
{ error: 'Profile not set up', userId: user.id, needsProfile: true },
403,
)
} }
// user.username holds the profileId stored on the Directus user record
const { token, expiresIn } = await issueJwt(user.id, user.username) const { token, expiresIn } = await issueJwt(user.id, user.username)
return c.json({ token, expiresIn }) return c.json({ token, expiresIn })
} catch (err: any) { } catch (err: any) {
return c.json({ error: err.message || 'Login failed' }, 401) // Gleicher Fehlertext für falsche E-Mail und falsches Passwort (User-Enumeration verhindern)
return c.json({ error: 'E-Mail oder Passwort falsch' }, 401)
} }
}) })
// GET /me [requireAuth] // ── GET /me ───────────────────────────────────────────────────────────────────
auth.get('/me', requireAuth, async (c) => { auth.get('/me', requireAuth, async (c) => {
const payload = c.get('jwtPayload') as JwtPayload const payload = c.get('jwtPayload') as JwtPayload
try { try {
const profile = await dGetProfileData(payload.sub) const profile = await dGetProfileData(payload.sub)
if (!profile) { if (!profile) return c.json({ error: 'Profil nicht gefunden' }, 404)
return c.json({ error: 'Profile not found' }, 404)
}
return c.json(profile) return c.json(profile)
} catch (err: any) { } catch (err: any) {
return c.json({ error: err.message || 'Failed to fetch profile' }, 500) return c.json({ error: 'Profil konnte nicht geladen werden' }, 500)
} }
}) })
// GET /check-username?username=xxx [public] // ── GET /check-username ───────────────────────────────────────────────────────
auth.get('/check-username', async (c) => { auth.get('/check-username', async (c) => {
const username = c.req.query('username') const username = c.req.query('username')
if (!username) { if (!username) return c.json({ error: 'username Parameter fehlt' }, 400)
return c.json({ error: 'username query parameter is required' }, 400) if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) return c.json({ available: false }) // ungültiges Format = nie verfügbar
}
try { try {
const available = await dCheckUsername(username) const available = await dCheckUsername(username)
return c.json({ available }) return c.json({ available })
} catch (err: any) { } catch {
return c.json({ error: err.message || 'Failed to check username' }, 500) return c.json({ error: 'Username-Check fehlgeschlagen' }, 500)
} }
}) })

View File

@@ -5,103 +5,89 @@ import {
dGetWords, dGetWords,
dGetQuestions, dGetQuestions,
dGetQAPairsAtLevel, dGetQAPairsAtLevel,
dAssetUrl, fetchAsset,
UUID_RE,
} from '../lib/directus' } from '../lib/directus'
import { requireAuth } from '../middleware/auth' import { requireAuth } from '../middleware/auth'
import type { JwtPayload } from '../middleware/auth' import type { JwtPayload } from '../middleware/auth'
// H-5: Erlaubte Sprach-Suffixes
const ALLOWED_LANGS = new Set(['de', 'en', 'se'])
const content = new Hono() const content = new Hono()
// GET /languages (public) // ── GET /languages (public) ──────────────────────────────────────────────────
content.get('/languages', async (c) => { content.get('/languages', async (c) => {
try { try {
const languages = await dGetLanguages() return c.json(await dGetLanguages())
return c.json(languages) } catch {
} catch (err: any) { return c.json({ error: 'Sprachen konnten nicht geladen werden' }, 500)
return c.json({ error: err.message || 'Failed to fetch languages' }, 500)
} }
}) })
// GET /pair [auth] // ── GET /pair [auth] ─────────────────────────────────────────────────────────
content.get('/pair', requireAuth, async (c) => { content.get('/pair', requireAuth, async (c) => {
const payload = c.get('jwtPayload') as JwtPayload const { username } = c.get('jwtPayload') as JwtPayload
try { try {
const pair = await dGetActivePair(payload.username) const pair = await dGetActivePair(username)
if (!pair) { if (!pair) return c.json({ error: 'Kein aktives Sprachpaar gefunden' }, 404)
return c.json({ error: 'No active learning pair found' }, 404)
}
return c.json(pair) return c.json(pair)
} catch (err: any) { } catch {
return c.json({ error: err.message || 'Failed to fetch active pair' }, 500) return c.json({ error: 'Sprachpaar konnte nicht geladen werden' }, 500)
} }
}) })
// GET /words [auth] // ── GET /words [auth] ────────────────────────────────────────────────────────
content.get('/words', requireAuth, async (c) => { content.get('/words', requireAuth, async (c) => {
try { try { return c.json(await dGetWords()) }
const words = await dGetWords() catch { return c.json({ error: 'Wörter konnten nicht geladen werden' }, 500) }
return c.json(words)
} catch (err: any) {
return c.json({ error: err.message || 'Failed to fetch words' }, 500)
}
}) })
// GET /questions [auth] // ── GET /questions [auth] ────────────────────────────────────────────────────
content.get('/questions', requireAuth, async (c) => { content.get('/questions', requireAuth, async (c) => {
try { try { return c.json(await dGetQuestions()) }
const questions = await dGetQuestions() catch { return c.json({ error: 'Fragen konnten nicht geladen werden' }, 500) }
return c.json(questions)
} catch (err: any) {
return c.json({ error: err.message || 'Failed to fetch questions' }, 500)
}
}) })
// GET /qa-pairs [auth] ?level=1&lang=de // ── GET /qa-pairs?level=1&lang=de [auth] ────────────────────────────────────
content.get('/qa-pairs', requireAuth, async (c) => { content.get('/qa-pairs', requireAuth, async (c) => {
const levelParam = c.req.query('level') const levelParam = c.req.query('level')
const lang = c.req.query('lang') || 'de' const lang = c.req.query('lang') || 'de'
if (!levelParam) { if (!levelParam) return c.json({ error: 'level Parameter fehlt' }, 400)
return c.json({ error: 'level query parameter is required' }, 400)
}
const level = parseInt(levelParam, 10) const level = parseInt(levelParam, 10)
if (isNaN(level)) { if (isNaN(level) || level < 1 || level > 100) return c.json({ error: 'level muss eine Zahl zwischen 1 und 100 sein' }, 400)
return c.json({ error: 'level must be a number' }, 400)
}
try { // H-5: Sprach-Whitelist
const pairs = await dGetQAPairsAtLevel(level, lang) if (!ALLOWED_LANGS.has(lang)) return c.json({ error: 'Ungültiger lang Parameter' }, 400)
return c.json(pairs)
} catch (err: any) { try { return c.json(await dGetQAPairsAtLevel(level, lang)) }
return c.json({ error: err.message || 'Failed to fetch QA pairs' }, 500) catch { return c.json({ error: 'QA-Pairs konnten nicht geladen werden' }, 500) }
}
}) })
// GET /assets/:fileId public (images are non-sensitive learning content) // ── GET /assets/:fileId (public, rate-limited in index.ts) ──────────────────
// K-1 Fix: fetchAsset nutzt Authorization-Header (Token nicht in URL/Logs)
content.get('/assets/:fileId', async (c) => { content.get('/assets/:fileId', async (c) => {
const fileId = c.req.param('fileId') const fileId = c.req.param('fileId')
if (!fileId) return c.json({ error: 'fileId required' }, 400)
const assetUrl = dAssetUrl(fileId)
try { // K-1: UUID-Format erzwingen (verhindert Path-Traversal / SSRF)
const upstream = await fetch(assetUrl) if (!fileId || !UUID_RE.test(fileId)) {
if (!upstream.ok) { return c.json({ error: 'Ungültiges fileId-Format' }, 400)
return c.json({ error: 'Asset not found' }, upstream.status as any)
} }
const contentType = upstream.headers.get('Content-Type') || 'application/octet-stream' try {
const body = upstream.body const upstream = await fetchAsset(fileId)
if (!upstream.ok) return c.json({ error: 'Asset nicht gefunden' }, 404)
return new Response(body, { const contentType = upstream.headers.get('Content-Type') || 'application/octet-stream'
status: 200, return new Response(upstream.body, {
headers: { headers: {
'Content-Type': contentType, 'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400', 'Cache-Control': 'public, max-age=86400',
}, },
}) })
} catch (err: any) { } catch {
return c.json({ error: err.message || 'Failed to fetch asset' }, 500) return c.json({ error: 'Asset konnte nicht geladen werden' }, 500)
} }
}) })

View File

@@ -1,59 +1,61 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { dGetProgress, dSaveProgress, dUpdatePairPoints } from '../lib/directus' import { dGetProgress, dSaveProgress, dUpdatePairPoints, UUID_RE } from '../lib/directus'
import { requireAuth } from '../middleware/auth' import { requireAuth } from '../middleware/auth'
import type { JwtPayload } from '../middleware/auth' import type { JwtPayload } from '../middleware/auth'
const progress = new Hono() // M-1: Erlaubte Enum-Werte
const ALLOWED_CARD_TYPES = new Set(['write', 'speak', 'sentence_fill'])
const ALLOWED_RESULTS = new Set(['correct', 'wrong', 'skipped'])
// All routes require auth const progress = new Hono()
progress.use('*', requireAuth) progress.use('*', requireAuth)
// GET /progress ?lang=de // ── GET /progress?lang=<uuid> [auth] ─────────────────────────────────────────
progress.get('/progress', async (c) => { progress.get('/progress', async (c) => {
const payload = c.get('jwtPayload') as JwtPayload const { username } = c.get('jwtPayload') as JwtPayload
const lang = c.req.query('lang') const lang = c.req.query('lang')
// lang muss entweder fehlen oder eine gültige UUID sein (Directus language_option ID)
if (lang && !UUID_RE.test(lang)) return c.json({ error: 'Ungültiger lang Parameter' }, 400)
try { try {
const data = await dGetProgress(payload.username, lang) return c.json(await dGetProgress(username, lang))
return c.json(data) } catch {
} catch (err: any) { return c.json({ error: 'Fortschritt konnte nicht geladen werden' }, 500)
return c.json({ error: err.message || 'Failed to fetch progress' }, 500)
} }
}) })
// POST /progress // ── POST /progress [auth] ────────────────────────────────────────────────────
progress.post('/progress', async (c) => { progress.post('/progress', async (c) => {
const payload = c.get('jwtPayload') as JwtPayload const { username } = c.get('jwtPayload') as JwtPayload
let body: { let body: any
word?: string | null try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
question?: string | null
card_type?: string
result?: string
points_earned?: number
language_from?: string
language_to?: string
}
try {
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
const { word, question, card_type, result, points_earned, language_from, language_to } = body const { word, question, card_type, result, points_earned, language_from, language_to } = body
if (!card_type || !result || points_earned === undefined || !language_from || !language_to) { // M-1: Server-seitige Validierung aller Felder
return c.json( if (!card_type || !ALLOWED_CARD_TYPES.has(card_type)) {
{ error: 'card_type, result, points_earned, language_from, and language_to are required' }, return c.json({ error: `card_type muss einer von: ${[...ALLOWED_CARD_TYPES].join(', ')} sein` }, 400)
400,
)
} }
if (!result || !ALLOWED_RESULTS.has(result)) {
return c.json({ error: `result muss einer von: ${[...ALLOWED_RESULTS].join(', ')} sein` }, 400)
}
if (typeof points_earned !== 'number' || !Number.isInteger(points_earned) ||
points_earned < 0 || points_earned > 100) {
return c.json({ error: 'points_earned muss eine ganze Zahl zwischen 0 und 100 sein' }, 400)
}
if (!language_from || !UUID_RE.test(language_from)) return c.json({ error: 'Ungültige language_from' }, 400)
if (!language_to || !UUID_RE.test(language_to)) return c.json({ error: 'Ungültige language_to' }, 400)
if (word && !UUID_RE.test(word)) return c.json({ error: 'Ungültige word-UUID' }, 400)
if (question && !UUID_RE.test(question)) return c.json({ error: 'Ungültige question-UUID' }, 400)
try { try {
// user kommt immer aus dem JWT — nie aus dem Body (verhindert User-Spoofing)
const saved = await dSaveProgress({ const saved = await dSaveProgress({
user: payload.username, user: username,
word: word ?? null, word: word || null,
question: question ?? null, question: question || null,
card_type, card_type,
result, result,
points_earned, points_earned,
@@ -61,35 +63,32 @@ progress.post('/progress', async (c) => {
language_to, language_to,
}) })
return c.json(saved, 201) return c.json(saved, 201)
} catch (err: any) { } catch {
return c.json({ error: err.message || 'Failed to save progress' }, 500) return c.json({ error: 'Fortschritt konnte nicht gespeichert werden' }, 500)
} }
}) })
// PATCH /pair/:pairId/points { points } // ── PATCH /pair/:pairId/points [auth] ────────────────────────────────────────
progress.patch('/pair/:pairId/points', async (c) => { progress.patch('/pair/:pairId/points', async (c) => {
const { username } = c.get('jwtPayload') as JwtPayload
const pairId = c.req.param('pairId') const pairId = c.req.param('pairId')
let body: { points?: number } // N-2: UUID-Format erzwingen
try { if (!pairId || !UUID_RE.test(pairId)) return c.json({ error: 'Ungültiges pairId-Format' }, 400)
body = await c.req.json()
} catch { let body: any
return c.json({ error: 'Invalid JSON body' }, 400) try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
const { points } = body
if (typeof points !== 'number' || !Number.isInteger(points) || points < 0) {
return c.json({ error: 'points muss eine nicht-negative ganze Zahl sein' }, 400)
} }
if (body.points === undefined || typeof body.points !== 'number') { // K-3: Ownership-Check passiert in dUpdatePairPoints
return c.json({ error: 'points (number) is required' }, 400) const outcome = await dUpdatePairPoints(pairId, points, username)
} if (outcome === 'forbidden') return c.json({ error: 'Zugriff verweigert' }, 403)
if (outcome === 'not_found') return c.json({ error: 'Sprachpaar nicht gefunden' }, 404)
try {
const ok = await dUpdatePairPoints(pairId, body.points)
if (!ok) {
return c.json({ error: 'Failed to update pair points' }, 500)
}
return c.json({ ok: true }) return c.json({ ok: true })
} catch (err: any) {
return c.json({ error: err.message || 'Failed to update pair points' }, 500)
}
}) })
export default progress export default progress