Harden runtime config and input validation

This commit is contained in:
Remi
2025-09-18 13:39:16 +02:00
committed by itsrubberduck
parent 88497a3848
commit e17169655d
10 changed files with 295 additions and 36 deletions

View File

@@ -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<AudioFormat>(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",

View File

@@ -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<Buffer> {
async function piperTTS(text: string, voice: string, port: number): Promise<Buffer> {
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";

View File

@@ -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<RegisterBody>(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' })