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:
47
src/index.ts
47
src/index.ts
@@ -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)
|
||||||
|
|||||||
@@ -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
37
src/lib/rateLimit.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + JWT_EXPIRY_SECONDS
|
||||||
async function issueJwt(sub: string, username: string): Promise<{ token: string; expiresIn: number }> {
|
const token = await sign({ sub, username, exp } as JwtPayload, JWT_SECRET())
|
||||||
const secret = process.env.JWT_SECRET!
|
|
||||||
const exp = Math.floor(Date.now() / 1000) + JWT_EXPIRY_SECONDS
|
|
||||||
const payload: JwtPayload = { sub, username, exp }
|
|
||||||
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: 3–20 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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
return c.json({ error: 'level query parameter is required' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!levelParam) return c.json({ error: 'level Parameter fehlt' }, 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)
|
// K-1: UUID-Format erzwingen (verhindert Path-Traversal / SSRF)
|
||||||
|
if (!fileId || !UUID_RE.test(fileId)) {
|
||||||
|
return c.json({ error: 'Ungültiges fileId-Format' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(assetUrl)
|
const upstream = await fetchAsset(fileId)
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) return c.json({ error: 'Asset nicht gefunden' }, 404)
|
||||||
return c.json({ error: 'Asset not found' }, upstream.status as any)
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = upstream.headers.get('Content-Type') || 'application/octet-stream'
|
const contentType = upstream.headers.get('Content-Type') || 'application/octet-stream'
|
||||||
const body = upstream.body
|
return new Response(upstream.body, {
|
||||||
|
|
||||||
return new Response(body, {
|
|
||||||
status: 200,
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
return c.json({ ok: true })
|
||||||
const ok = await dUpdatePairPoints(pairId, body.points)
|
|
||||||
if (!ok) {
|
|
||||||
return c.json({ error: 'Failed to update pair points' }, 500)
|
|
||||||
}
|
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user