mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 17:05:53 +08:00
Harden runtime config and input validation
This commit is contained in:
37
content/news/2025-09-18-runtime-hardening.md
Normal file
37
content/news/2025-09-18-runtime-hardening.md
Normal file
@@ -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!
|
||||
@@ -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,
|
||||
|
||||
28
public/img/news/runtime-hardening.svg
Normal file
28
public/img/news/runtime-hardening.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="630" rx="32" fill="#0B1020"/>
|
||||
<g filter="url(#glow)">
|
||||
<circle cx="260" cy="180" r="220" fill="#22D3EE" fill-opacity="0.25"/>
|
||||
<circle cx="960" cy="420" r="280" fill="#0EA5E9" fill-opacity="0.18"/>
|
||||
</g>
|
||||
<rect x="100" y="140" width="440" height="280" rx="24" stroke="#22D3EE" stroke-opacity="0.4" stroke-width="2" fill="url(#card)"/>
|
||||
<rect x="660" y="220" width="440" height="220" rx="24" stroke="#38BDF8" stroke-opacity="0.4" stroke-width="2" fill="url(#card)"/>
|
||||
<text x="120" y="220" fill="#E2E8F0" font-size="54" font-weight="700" font-family="'Inter', 'Segoe UI', sans-serif">Runtime Hardening</text>
|
||||
<text x="120" y="280" fill="#A5F3FC" font-size="24" font-family="'Inter', 'Segoe UI', sans-serif">Config • Validation • ATC Audio</text>
|
||||
<text x="120" y="340" fill="#94A3B8" font-size="20" font-family="'Inter', 'Segoe UI', sans-serif">Validierte Eingaben & klare Fehlermeldungen halten</text>
|
||||
<text x="120" y="370" fill="#94A3B8" font-size="20" font-family="'Inter', 'Segoe UI', sans-serif">die Alpha-Testumgebung stabil.</text>
|
||||
<path d="M760 270L900 270" stroke="#22D3EE" stroke-width="6" stroke-linecap="round"/>
|
||||
<path d="M760 330L960 330" stroke="#22D3EE" stroke-width="6" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M760 390L930 390" stroke="#38BDF8" stroke-width="6" stroke-linecap="round" opacity="0.5"/>
|
||||
<circle cx="760" cy="270" r="10" fill="#22D3EE"/>
|
||||
<circle cx="760" cy="330" r="10" fill="#22D3EE"/>
|
||||
<circle cx="760" cy="390" r="10" fill="#38BDF8"/>
|
||||
<defs>
|
||||
<filter id="glow" x="-120" y="-120" width="1440" height="870" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feGaussianBlur stdDeviation="80" result="blur"/>
|
||||
</filter>
|
||||
<linearGradient id="card" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#0F172A" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#0B1120" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<typeof OpenAI>[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)
|
||||
|
||||
@@ -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<typeof OpenAI>[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<string> {
|
||||
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<LLMDecisio
|
||||
const user = JSON.stringify(optimizedInput)
|
||||
|
||||
try {
|
||||
const r = await openai.chat.completions.create({
|
||||
model: MODEL,
|
||||
const client = ensureOpenAI()
|
||||
const model = getModel()
|
||||
const r = await client.chat.completions.create({
|
||||
model,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
|
||||
83
server/utils/runtimeConfig.ts
Normal file
83
server/utils/runtimeConfig.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
|
||||
export interface ServerRuntimeConfig {
|
||||
openaiKey: string
|
||||
openaiProject?: string
|
||||
llmModel: string
|
||||
ttsModel: string
|
||||
voiceId: string
|
||||
usePiper: boolean
|
||||
piperPort: number
|
||||
useSpeaches: boolean
|
||||
speachesBaseUrl?: string
|
||||
speechModelId: string
|
||||
}
|
||||
|
||||
let cachedConfig: ServerRuntimeConfig | null = null
|
||||
let warnedMissingOpenAIKey = false
|
||||
|
||||
function toBoolean(value: unknown, fallback = false): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return fallback
|
||||
}
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
||||
return true
|
||||
}
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback: number): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function getServerRuntimeConfig(): ServerRuntimeConfig {
|
||||
if (cachedConfig) {
|
||||
return cachedConfig
|
||||
}
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
|
||||
const openaiKey = String(runtimeConfig.openaiKey || '').trim()
|
||||
if (!openaiKey && !warnedMissingOpenAIKey) {
|
||||
console.warn('[OpenSquawk] OPENAI_API_KEY fehlt. Einige KI-Funktionen stehen ohne Schlüssel nicht zur Verfügung.')
|
||||
warnedMissingOpenAIKey = true
|
||||
}
|
||||
|
||||
const config: ServerRuntimeConfig = {
|
||||
openaiKey,
|
||||
openaiProject: String(runtimeConfig.openaiProject || '').trim() || undefined,
|
||||
llmModel: String(runtimeConfig.llmModel || '').trim() || 'gpt-5-nano',
|
||||
ttsModel: String(runtimeConfig.ttsModel || '').trim() || 'tts-1',
|
||||
voiceId: String(runtimeConfig.defaultVoiceId || '').trim() || 'alloy',
|
||||
usePiper: toBoolean(runtimeConfig.usePiper),
|
||||
piperPort: toNumber(runtimeConfig.piperPort, 5001),
|
||||
useSpeaches: toBoolean(runtimeConfig.useSpeaches),
|
||||
speachesBaseUrl: String(runtimeConfig.speachesBaseUrl || '').trim() || undefined,
|
||||
speechModelId: String(runtimeConfig.speechModelId || '').trim() || 'speaches-ai/piper-en_US-ryan-low',
|
||||
}
|
||||
|
||||
cachedConfig = config
|
||||
return config
|
||||
}
|
||||
|
||||
export function resetServerRuntimeConfigCache() {
|
||||
cachedConfig = null
|
||||
}
|
||||
27
server/utils/validation.ts
Normal file
27
server/utils/validation.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/i
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return EMAIL_REGEX.test(email.trim())
|
||||
}
|
||||
|
||||
export interface PasswordValidationResult {
|
||||
valid: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function validatePasswordStrength(password: string): PasswordValidationResult {
|
||||
const trimmed = password.trim()
|
||||
if (trimmed.length < 10) {
|
||||
return { valid: false, message: 'Passwort muss mindestens 10 Zeichen lang sein.' }
|
||||
}
|
||||
if (/\s/.test(trimmed)) {
|
||||
return { valid: false, message: 'Passwort darf keine Leerzeichen enthalten.' }
|
||||
}
|
||||
if (!/[A-Za-zÄÖÜäöüß]/.test(trimmed) || !/[0-9]/.test(trimmed)) {
|
||||
return { valid: false, message: 'Bitte Buchstaben und Zahlen kombinieren.' }
|
||||
}
|
||||
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(trimmed)) {
|
||||
return { valid: false, message: 'Mindestens ein Sonderzeichen erhöht die Sicherheit.' }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
Reference in New Issue
Block a user