diff --git a/content/news/2025-09-18-runtime-hardening.md b/content/news/2025-09-18-runtime-hardening.md new file mode 100644 index 0000000..8de072f --- /dev/null +++ b/content/news/2025-09-18-runtime-hardening.md @@ -0,0 +1,37 @@ +--- +title: "Runtime-Härtung & sichere Eingaben" +date: "2025-09-18" +summary: "Neue Konfigurationszentrale, sichere Audio-Uploads und strengere Passwörter machen OpenSquawk robuster." +readingTime: "3 Min Lesezeit" +banner: "runtime-hardening.svg" +--- + +Wir haben uns heute quer durch Backend, Runtime-Config und Onboarding gearbeitet, um OpenSquawk widerstandsfähiger zu machen. Drei Baustellen standen im Fokus: reproduzierbare Konfiguration, saubere Audio-Eingaben für den Funk-Workflow und ein härterer Schutz der Benutzerkonten. + +## Highlights + +- **Zentrale Runtime-Konfiguration:** Alle sensiblen Schlüssel (OpenAI, TTS, Piper & Speaches) laufen jetzt über eine gemeinsame Helper-Funktion. Fehlkonfigurationen fliegen sofort auf, inklusive klarer Fehlermeldung. +- **Sicherere Funk-Eingaben:** Die PTT-API akzeptiert nur noch validiertes Base64-Audio bis 2 MB und standardisiert unbekannte Formate auf WAV, bevor Whisper loslegt. +- **Stärkere Passwörter:** Registrierung prüft E-Mail-Format und Passwort-Qualität (Länge, Buchstaben/Zahlen, Sonderzeichen), damit Alpha-Zugänge nicht mit Trivialpasswörtern angelegt werden. + +## Details + +### Runtime-Konfiguration aufgeräumt + +Ein neues Utility (`server/utils/runtimeConfig.ts`) kapselt sämtliche Laufzeit-Variablen. `OpenAI`- und TTS-Clients ziehen daraus konsistent Schlüssel, Modelle, Voice-Defaults sowie Piper/Speaches-Schalter. Der Server stoppt mit einem erklärenden Fehler, falls `OPENAI_API_KEY` fehlt – besser früh scheitern als stumm 500er produzieren. + +### Audio-Endpunkte härter gemacht + +Die Push-to-Talk-Route prüft Base64-Eingaben, begrenzt Dateigrößen auf praxisnahe 2 MB (~60 s Funk) und erzwingt bekannte Audioformate. Das reduziert Risiko von Speicherausreißern und sorgt dafür, dass FFmpeg nur bei echten Konvertierungen anspringt. Gleichzeitig lesen TTS-Endpunkte ihre Einstellungen jetzt aus der Runtime-Config und respektieren lokale Piper-Ports sowie Speaches-Basis-URLs. + +### Onboarding abgesichert + +Die Registrierung verweigert invaliden Input ab sofort sofort: ungültige E-Mails, kurze Passwörter oder fehlende Sonderzeichen liefern verständliche Fehlermeldungen. So bleiben Test-Accounts verwaltbar und Sicherheitsstandards steigen, ohne den Flow zu bremsen. + +## Ausblick + +- Hotjar/Analytics-Opt-in an die neue Config-Logik anbinden. +- Für den Login dieselben Passwort-Guidelines rückspiegeln (Feedback-UI). +- Größere Audiodateien optional asynchron verarbeiten, falls Langform-Transkripte spannend werden. + +Wenn euch weitere Hardenings einfallen: gerne Issues aufmachen oder direkt PRs schicken! diff --git a/nuxt.config.ts b/nuxt.config.ts index d8b29af..2149a3f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -15,8 +15,15 @@ export default defineNuxtConfig({ app: {head: {link: [{rel: 'icon', type: 'image/jpeg', href: '/img/logo.jpeg'}]}}, runtimeConfig: { openaiKey: process.env.OPENAI_API_KEY, + openaiProject: process.env.OPENAI_PROJECT, llmModel: process.env.LLM_MODEL || 'gpt-5-nano', ttsModel: process.env.TTS_MODEL || 'tts-1', + defaultVoiceId: process.env.VOICE_ID || 'alloy', + usePiper: process.env.USE_PIPER, + piperPort: process.env.PIPER_PORT, + useSpeaches: process.env.USE_SPEACHES, + speachesBaseUrl: process.env.SPEACHES_BASE_URL, + speechModelId: process.env.SPEECH_MODEL_ID, jwtSecret: process.env.JWT_SECRET, jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET, manualInvitePassword: process.env.MANUAL_INVITE_PASSWORD, diff --git a/public/img/news/runtime-hardening.svg b/public/img/news/runtime-hardening.svg new file mode 100644 index 0000000..f4be7db --- /dev/null +++ b/public/img/news/runtime-hardening.svg @@ -0,0 +1,28 @@ + + + + + + + + + Runtime Hardening + Config • Validation • ATC Audio + Validierte Eingaben & klare Fehlermeldungen halten + die Alpha-Testumgebung stabil. + + + + + + + + + + + + + + + + diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index cdc27b2..c8f4958 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -5,11 +5,13 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; -import { openai, routeDecision } from "../../utils/openai"; +import { getOpenAIClient, routeDecision } from "../../utils/openai"; import { createReadStream } from "node:fs"; import { TransmissionLog } from "../../models/TransmissionLog"; import { getUserFromEvent } from "../../utils/auth"; +type AudioFormat = 'wav' | 'mp3' | 'ogg' | 'webm' + interface PTTRequest { audio: string; // Base64 encoded audio context: { @@ -21,7 +23,7 @@ interface PTTRequest { }; moduleId: string; lessonId: string; - format?: 'wav' | 'mp3' | 'ogg' | 'webm'; + format?: AudioFormat; autoDecide?: boolean; } @@ -44,6 +46,37 @@ async function sh(cmd: string, args: string[]) { ); } +const BASE64_AUDIO_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; +const MAX_AUDIO_BYTES = 2 * 1024 * 1024; // ~60 Sekunden 16kHz Mono +const ALLOWED_AUDIO_FORMATS: AudioFormat[] = ['wav', 'mp3', 'ogg', 'webm']; +const AUDIO_FORMAT_SET = new Set(ALLOWED_AUDIO_FORMATS); + +function resolveAudioFormat(format?: string | null): AudioFormat { + if (!format) { + return 'wav'; + } + const normalized = format.trim().toLowerCase() as AudioFormat; + return AUDIO_FORMAT_SET.has(normalized) ? normalized : 'wav'; +} + +function decodeAudioPayload(encoded: string): Buffer { + const sanitized = encoded.replace(/\s+/g, ''); + if (!sanitized) { + throw createError({ statusCode: 400, statusMessage: 'Audio payload is empty' }); + } + if (!BASE64_AUDIO_REGEX.test(sanitized)) { + throw createError({ statusCode: 400, statusMessage: 'Audio payload is not valid base64' }); + } + const buffer = Buffer.from(sanitized, 'base64'); + if (!buffer.length) { + throw createError({ statusCode: 400, statusMessage: 'Decoded audio payload is empty' }); + } + if (buffer.length > MAX_AUDIO_BYTES) { + throw createError({ statusCode: 413, statusMessage: 'Audio payload exceeds the 2 MB limit' }); + } + return buffer; +} + // Audio zu WAV konvertieren für bessere Whisper-Kompatibilität async function convertToWav(inputPath: string, outputPath: string) { await sh("ffmpeg", [ @@ -66,17 +99,18 @@ export default defineEventHandler(async (event) => { } const id = randomUUID(); - const tmpAudioInput = join(tmpdir(), `ptt-input-${id}.${body.format || 'wav'}`); + const format = resolveAudioFormat(body.format); + const tmpAudioInput = join(tmpdir(), `ptt-input-${id}.${format}`); const tmpAudioWav = join(tmpdir(), `ptt-wav-${id}.wav`); try { // 1. Audio aus Base64 dekodieren und speichern - const audioBuffer = Buffer.from(body.audio, 'base64'); + const audioBuffer = decodeAudioPayload(body.audio); await writeFile(tmpAudioInput, audioBuffer); // 2. Zu WAV konvertieren falls nötig (nur wenn FFmpeg verfügbar) let audioFileForWhisper = tmpAudioInput; - if (body.format !== 'wav') { + if (format !== 'wav') { try { await convertToWav(tmpAudioInput, tmpAudioWav); audioFileForWhisper = tmpAudioWav; @@ -86,6 +120,7 @@ export default defineEventHandler(async (event) => { } // 3. OpenAI Whisper für Transkription + const openai = getOpenAIClient(); const transcription = await openai.audio.transcriptions.create({ file: createReadStream(audioFileForWhisper), model: "whisper-1", diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index 6cf0117..afa3abe 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -5,15 +5,11 @@ import {existsSync} from "node:fs"; import {join} from "node:path"; import {randomUUID} from "node:crypto"; import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize"; +import { getServerRuntimeConfig } from "../../utils/runtimeConfig"; import {request} from "node:http"; import { TransmissionLog } from "../../models/TransmissionLog"; import { getUserFromEvent } from "../../utils/auth"; -// dotenv config -import {config} from "dotenv"; - -config(); - function outDir() { return process.env.ATC_OUT_DIR?.trim() || join(process.cwd(), "storage", "atc"); @@ -60,12 +56,12 @@ function fmtToExt(fmt: AudioFmt): string { } // ---- Piper HTTP helper ---- -async function piperTTS(text: string, voice: string): Promise { +async function piperTTS(text: string, voice: string, port: number): Promise { return new Promise((resolve, reject) => { const req = request( { hostname: "localhost", - port: Number(process.env.PIPER_PORT ?? 5001), + port, path: "/", method: "POST", headers: { "Content-Type": "application/json" } @@ -117,6 +113,7 @@ async function speachesTTS( } export default defineEventHandler(async (event) => { + const runtimeConfig = getServerRuntimeConfig(); const body = await readBody<{ text?: string; level?: number; @@ -132,15 +129,15 @@ export default defineEventHandler(async (event) => { if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" }); const level = Math.max(1, Math.min(5, Math.floor(body?.level ?? 4))); - const voice = (body?.voice || process.env.VOICE_ID || "alloy").trim(); + const voice = (body?.voice || runtimeConfig.voiceId).trim(); const speed = Math.max(0.5, Math.min(2.0, body?.speed || 1.0)); const normalized = normalizeATC(raw); if (!normalized) throw createError({ statusCode: 400, statusMessage: "normalized text empty" }); // Routing - const useSpeaches = (process.env.USE_SPEACHES || "").toLowerCase() === "true"; - const usePiper = !useSpeaches && (process.env.USE_PIPER || "").toLowerCase() === "true"; + const useSpeaches = runtimeConfig.useSpeaches; + const usePiper = !useSpeaches && runtimeConfig.usePiper; // Format const requestedFmt = (body?.format === "smallest" ? "mp3" : body?.format) as AudioFmt | undefined; @@ -163,8 +160,8 @@ export default defineEventHandler(async (event) => { if (useSpeaches) { // Speaches (bevorzugt klein: MP3, alternativ FLAC/WAV/PCM) - const baseUrl = process.env.SPEACHES_BASE_URL || ""; - const model = process.env.SPEECH_MODEL_ID || "speaches-ai/piper-en_US-ryan-low"; + const baseUrl = runtimeConfig.speachesBaseUrl || ""; + const model = runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low"; if (!baseUrl) { throw new Error("SPEACHES_BASE_URL not set"); } @@ -174,7 +171,7 @@ export default defineEventHandler(async (event) => { actualMime = fmtToMime(fmt); } else if (usePiper) { // Lokaler Piper - audioBuffer = await piperTTS(normalized, voice); + audioBuffer = await piperTTS(normalized, voice, runtimeConfig.piperPort); modelUsed = "piper-local"; // Piper liefert WAV actualMime = "audio/wav"; diff --git a/server/api/service/auth/register.post.ts b/server/api/service/auth/register.post.ts index 3bb0997..187f74b 100644 --- a/server/api/service/auth/register.post.ts +++ b/server/api/service/auth/register.post.ts @@ -3,6 +3,7 @@ import { hashPassword, issueAuthTokens } from '../../../utils/auth' import { User } from '../../../models/User' import { InvitationCode } from '../../../models/InvitationCode' import { WaitlistEntry } from '../../../models/WaitlistEntry' +import { isValidEmail, validatePasswordStrength } from '../../../utils/validation' interface RegisterBody { email?: string @@ -15,12 +16,13 @@ interface RegisterBody { export default defineEventHandler(async (event) => { const body = await readBody(event) - const email = body.email?.trim().toLowerCase() - const password = body.password?.trim() + const emailInput = body.email?.trim() || '' + const password = body.password?.trim() || '' const name = body.name?.trim() const code = body.invitationCode?.trim().toUpperCase() + const email = emailInput.toLowerCase() - if (!email || !password || !code) { + if (!emailInput || !password || !code) { throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail, Passwort und Einladungscode angeben' }) } @@ -28,6 +30,15 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: 'Bitte AGB und Datenschutz bestätigen' }) } + if (!isValidEmail(emailInput)) { + throw createError({ statusCode: 400, statusMessage: 'Bitte eine gültige E-Mail-Adresse angeben' }) + } + + const passwordValidation = validatePasswordStrength(password) + if (!passwordValidation.valid) { + throw createError({ statusCode: 400, statusMessage: passwordValidation.message || 'Passwort ist zu schwach' }) + } + const existingUser = await User.findOne({ email }) if (existingUser) { throw createError({ statusCode: 409, statusMessage: 'Für diese E-Mail existiert bereits ein Konto' }) diff --git a/server/utils/normalize.ts b/server/utils/normalize.ts index b1da124..2fdd9d8 100644 --- a/server/utils/normalize.ts +++ b/server/utils/normalize.ts @@ -1,17 +1,18 @@ -// yarn add openai dotenv +// yarn add openai import OpenAI from "openai"; -import dotenv from "dotenv"; import fs from "node:fs"; +import { getServerRuntimeConfig } from "./runtimeConfig"; -dotenv.config(); +const { openaiKey, openaiProject, llmModel, ttsModel } = getServerRuntimeConfig(); +const normalizeClientOptions: ConstructorParameters[0] = { apiKey: openaiKey }; +if (openaiProject) { + normalizeClientOptions.project = openaiProject; +} -export const normalize = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY!, - project: process.env.OPENAI_PROJECT, // optional -}); +export const normalize = new OpenAI(normalizeClientOptions); -export const LLM_MODEL = process.env.LLM_MODEL || "gpt-5-nano"; -export const TTS_MODEL = process.env.TTS_MODEL || "tts-1"; +export const LLM_MODEL = llmModel; +export const TTS_MODEL = ttsModel; /* ========================= LLM PROMPTS (überarbeitet) diff --git a/server/utils/openai.ts b/server/utils/openai.ts index cbd177e..70d36be 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -1,12 +1,43 @@ // server/utils/openai.ts import OpenAI from 'openai' +import { getServerRuntimeConfig } from './runtimeConfig' -const MODEL = process.env.LLM_MODEL || 'gpt-5-nano' -export const openai = new OpenAI({apiKey: process.env.OPENAI_API_KEY!}) +let openaiClient: OpenAI | null = null +let cachedModel: string | null = null + +function ensureOpenAI(): OpenAI { + if (!openaiClient) { + const { openaiKey, openaiProject, llmModel } = getServerRuntimeConfig() + if (!openaiKey) { + throw new Error('OPENAI_API_KEY fehlt. Bitte den Schlüssel setzen, bevor KI-Funktionen genutzt werden.') + } + const clientOptions: ConstructorParameters[0] = { apiKey: openaiKey } + if (openaiProject) { + clientOptions.project = openaiProject + } + openaiClient = new OpenAI(clientOptions) + cachedModel = llmModel + } + return openaiClient +} + +function getModel(): string { + if (!cachedModel) { + const { llmModel } = getServerRuntimeConfig() + cachedModel = llmModel + } + return cachedModel +} + +export function getOpenAIClient(): OpenAI { + return ensureOpenAI() +} export async function decide(system: string, user: string): Promise { - const r = await openai.chat.completions.create({ - model: MODEL, + const client = ensureOpenAI() + const model = getModel() + const r = await client.chat.completions.create({ + model, messages: [ {role: 'system', content: system}, {role: 'user', content: user} @@ -146,8 +177,10 @@ export async function routeDecision(input: LLMDecisionInput): Promise/?]/.test(trimmed)) { + return { valid: false, message: 'Mindestens ein Sonderzeichen erhöht die Sicherheit.' } + } + return { valid: true } +}