This commit is contained in:
itsrubberduck
2025-09-20 09:50:20 +02:00
35 changed files with 692 additions and 1137 deletions

View File

@@ -68,7 +68,7 @@ export default defineEventHandler(async (event) => {
if (channel) {
if (!CHANNELS.has(channel)) {
throw createError({ statusCode: 400, statusMessage: 'Unbekannter Kanal' })
throw createError({ statusCode: 400, statusMessage: 'Unknown channel' })
}
filter.channel = channel
}
@@ -83,7 +83,7 @@ export default defineEventHandler(async (event) => {
filter.usedBy = { $exists: false }
filter.expiresAt = { $lt: now }
} else {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Status' })
throw createError({ statusCode: 400, statusMessage: 'Invalid status' })
}
}

View File

@@ -69,21 +69,21 @@ export default defineEventHandler(async (event) => {
if (channel) {
if (!CHANNELS.has(channel)) {
throw createError({ statusCode: 400, statusMessage: 'Unbekannter Kanal' })
throw createError({ statusCode: 400, statusMessage: 'Unknown channel' })
}
filter.channel = channel as TransmissionLogDocument['channel']
}
if (direction) {
if (!DIRECTIONS.has(direction)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Richtung' })
throw createError({ statusCode: 400, statusMessage: 'Invalid direction' })
}
filter.direction = direction as TransmissionLogDocument['direction']
}
if (role) {
if (!ROLES.has(role)) {
throw createError({ statusCode: 400, statusMessage: 'Unbekannte Rolle' })
throw createError({ statusCode: 400, statusMessage: 'Unknown role' })
}
filter.role = role
}
@@ -91,7 +91,7 @@ export default defineEventHandler(async (event) => {
if (sinceRaw) {
const since = new Date(sinceRaw)
if (Number.isNaN(since.valueOf())) {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Zeitraum' })
throw createError({ statusCode: 400, statusMessage: 'Invalid timeframe' })
}
filter.createdAt = { ...(filter.createdAt as any), $gte: since }
}

View File

@@ -45,7 +45,7 @@ export default defineEventHandler(async (event) => {
if (role) {
if (!['user', 'admin', 'dev'].includes(role)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Rolle' })
throw createError({ statusCode: 400, statusMessage: 'Invalid role' })
}
filter.role = role
}

View File

@@ -12,19 +12,19 @@ export default defineEventHandler(async (event) => {
const userId = params?.id
if (!userId) {
throw createError({ statusCode: 400, statusMessage: 'User-ID fehlt' })
throw createError({ statusCode: 400, statusMessage: 'Missing user ID' })
}
const body = await readBody<UpdateRoleBody>(event).catch(() => ({}))
const role = body.role?.trim()
if (!role || !['user', 'admin', 'dev'].includes(role)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Rolle' })
throw createError({ statusCode: 400, statusMessage: 'Invalid role' })
}
const target = await User.findById(userId)
if (!target) {
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
throw createError({ statusCode: 404, statusMessage: 'User not found' })
}
const previousRole = target.role

View File

@@ -77,11 +77,11 @@ function decodeAudioPayload(encoded: string): Buffer {
return buffer;
}
// Audio zu WAV konvertieren für bessere Whisper-Kompatibilität
// Convert audio to WAV for better Whisper compatibility
async function convertToWav(inputPath: string, outputPath: string) {
await sh("ffmpeg", [
"-y", "-i", inputPath,
"-ar", "16000", // 16kHz für Whisper
"-ar", "16000", // 16 kHz for Whisper
"-ac", "1", // Mono
"-f", "wav",
outputPath
@@ -104,11 +104,11 @@ export default defineEventHandler(async (event) => {
const tmpAudioWav = join(tmpdir(), `ptt-wav-${id}.wav`);
try {
// 1. Audio aus Base64 dekodieren und speichern
// 1. Decode audio from base64 and save
const audioBuffer = decodeAudioPayload(body.audio);
await writeFile(tmpAudioInput, audioBuffer);
// 2. Zu WAV konvertieren falls nötig (nur wenn FFmpeg verfügbar)
// 2. Convert to WAV if needed (only when FFmpeg is available)
let audioFileForWhisper = tmpAudioInput;
if (format !== 'wav') {
try {
@@ -119,7 +119,7 @@ export default defineEventHandler(async (event) => {
}
}
// 3. OpenAI Whisper für Transkription
// 3. OpenAI Whisper for transcription
const openai = getOpenAIClient();
const transcription = await openai.audio.transcriptions.create({
file: createReadStream(audioFileForWhisper),
@@ -143,7 +143,7 @@ export default defineEventHandler(async (event) => {
let decision: PTTResponse['decision'];
if (shouldAutoDecide) {
// 4. Direkt LLM Decision aufrufen mit transkribiertem Text
// 4. Call the LLM decision directly with the transcribed text
const decisionInput = {
...body.context,
pilot_utterance: transcribedText
@@ -191,7 +191,7 @@ export default defineEventHandler(async (event) => {
return result;
} catch (error: any) {
// Cleanup bei Fehler
// Cleanup on error
await rm(tmpAudioInput).catch(() => {});
await rm(tmpAudioWav).catch(() => {});

View File

@@ -161,7 +161,7 @@ export default defineEventHandler(async (event) => {
let actualMime = mime;
if (useSpeaches) {
// Speaches (bevorzugt klein: MP3, alternativ FLAC/WAV/PCM)
// Speaches (prefer compact: MP3, otherwise FLAC/WAV/PCM)
const baseUrl = runtimeConfig.speachesBaseUrl || "";
const model = runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low";
if (!baseUrl) {
@@ -169,16 +169,16 @@ export default defineEventHandler(async (event) => {
}
audioBuffer = await speachesTTS(normalized, voice, model, fmt, baseUrl);
modelUsed = model;
// Server liefert korrektes Format gemäß response_format
// Server returns the correct format according to response_format
actualMime = fmtToMime(fmt);
} else if (usePiper) {
// Lokaler Piper
// Local Piper
audioBuffer = await piperTTS(normalized, voice, runtimeConfig.piperPort);
modelUsed = "piper-local";
// Piper liefert WAV
// Piper returns WAV
actualMime = "audio/wav";
} else {
// OpenAI (Fallback)
// OpenAI (fallback)
const tts = await normalize.audio.speech.create({
model: TTS_MODEL,
voice,
@@ -191,7 +191,7 @@ export default defineEventHandler(async (event) => {
actualMime = "audio/wav";
}
// Optional speichern
// Optional persistence
// await ensureDir(baseDir);
// await writeFile(fileOut, audioBuffer);
const meta = {

View File

@@ -14,16 +14,16 @@ export default defineEventHandler(async (event) => {
const twoWeeksMs = 1000 * 60 * 60 * 24 * 14
if (accountAgeMs < twoWeeksMs) {
throw createError({ statusCode: 403, statusMessage: 'Einladungscodes stehen nach 14 Tagen zur Verfügung' })
throw createError({ statusCode: 403, statusMessage: 'Invitation codes become available after 14 days' })
}
if (user.invitationCodesIssued >= 2) {
throw createError({ statusCode: 403, statusMessage: 'Limit von zwei Einladungscodes erreicht' })
throw createError({ statusCode: 403, statusMessage: 'Limit of two invitation codes reached' })
}
const activeCodes = await InvitationCode.countDocuments({ createdBy: user._id, usedBy: { $exists: false } })
if (activeCodes >= 2) {
throw createError({ statusCode: 403, statusMessage: 'Bitte vorhandene Einladungscodes zuerst nutzen' })
throw createError({ statusCode: 403, statusMessage: 'Please use your existing invitation codes first' })
}
const code = generateCode()

View File

@@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => {
try {
const { decision, trace } = await routeDecision(body)
// Log für Debugging bei off-schema oder radio check
// Log for debugging when off-schema or radio check triggers
if (decision.off_schema) {
console.log(`[ATC] Off-schema response for: "${body.pilot_utterance}"`)
}

View File

@@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => {
const email = body.email?.trim().toLowerCase()
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'Bitte eine E-Mail-Adresse angeben' })
throw createError({ statusCode: 400, statusMessage: 'Please provide an email address' })
}
const user = await User.findOne({ email })
@@ -32,21 +32,21 @@ export default defineEventHandler(async (event) => {
const salutation = user.name ? ` ${user.name}` : ''
const lines = [
`Hallo${salutation},`,
`Hi${salutation},`,
'',
'du hast den Link zum Zurücksetzen deines OpenSquawk-Passworts angefordert.',
`Klicke innerhalb von ${RESET_TOKEN_TTL_MINUTES} Minuten auf den folgenden Link, um ein neues Passwort festzulegen:`,
'You requested a link to reset your OpenSquawk password.',
`Click the link below within ${RESET_TOKEN_TTL_MINUTES} minutes to choose a new password:`,
resetLink,
'',
'Wenn du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.',
'If you did not make this request, you can safely ignore this email.',
'',
'Blue skies,',
'dein OpenSquawk-Team',
'Your OpenSquawk crew',
]
await sendMail({
to: user.email,
subject: 'OpenSquawk Passwort zurücksetzen',
subject: 'Reset your OpenSquawk password',
text: lines.join('\n'),
})
}

View File

@@ -13,17 +13,17 @@ export default defineEventHandler(async (event) => {
const password = body.password?.trim()
if (!email || !password) {
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail und Passwort angeben' })
throw createError({ statusCode: 400, statusMessage: 'Please provide email and password' })
}
const user = await User.findOne({ email })
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'Ungültige Zugangsdaten' })
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
throw createError({ statusCode: 401, statusMessage: 'Ungültige Zugangsdaten' })
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
}
user.lastLoginAt = new Date()

View File

@@ -23,36 +23,36 @@ export default defineEventHandler(async (event) => {
const email = emailInput.toLowerCase()
if (!emailInput || !password || !code) {
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail, Passwort und Einladungscode angeben' })
throw createError({ statusCode: 400, statusMessage: 'Please provide email, password and invitation code' })
}
if (!body.acceptPrivacy || !body.acceptTerms) {
throw createError({ statusCode: 400, statusMessage: 'Bitte AGB und Datenschutz bestätigen' })
throw createError({ statusCode: 400, statusMessage: 'Please accept the terms and privacy policy' })
}
if (!isValidEmail(emailInput)) {
throw createError({ statusCode: 400, statusMessage: 'Bitte eine gültige E-Mail-Adresse angeben' })
throw createError({ statusCode: 400, statusMessage: 'Please provide a valid email address' })
}
const passwordValidation = validatePasswordStrength(password)
if (!passwordValidation.valid) {
throw createError({ statusCode: 400, statusMessage: passwordValidation.message || 'Passwort ist zu schwach' })
throw createError({ statusCode: 400, statusMessage: passwordValidation.message || 'Password is too weak' })
}
const existingUser = await User.findOne({ email })
if (existingUser) {
throw createError({ statusCode: 409, statusMessage: 'Für diese E-Mail existiert bereits ein Konto' })
throw createError({ statusCode: 409, statusMessage: 'An account already exists for this email address' })
}
const invitation = await InvitationCode.findOne({ code })
if (!invitation) {
throw createError({ statusCode: 404, statusMessage: 'Einladungscode nicht gefunden' })
throw createError({ statusCode: 404, statusMessage: 'Invitation code not found' })
}
if (invitation.usedBy) {
throw createError({ statusCode: 400, statusMessage: 'Einladungscode wurde bereits verwendet' })
throw createError({ statusCode: 400, statusMessage: 'Invitation code has already been used' })
}
if (invitation.expiresAt && invitation.expiresAt < new Date()) {
throw createError({ statusCode: 400, statusMessage: 'Einladungscode ist abgelaufen' })
throw createError({ statusCode: 400, statusMessage: 'Invitation code has expired' })
}
const passwordHash = await hashPassword(password)

View File

@@ -15,24 +15,24 @@ export default defineEventHandler(async (event) => {
const password = body.password?.trim()
if (!token || !password) {
throw createError({ statusCode: 400, statusMessage: 'Token und neues Passwort erforderlich' })
throw createError({ statusCode: 400, statusMessage: 'Token and new password required' })
}
if (password.length < 8) {
throw createError({ statusCode: 400, statusMessage: 'Das Passwort muss mindestens 8 Zeichen enthalten' })
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 8 characters long' })
}
const tokenHash = createHash('sha256').update(token).digest('hex')
const resetToken = await PasswordResetToken.findOne({ tokenHash })
if (!resetToken || (resetToken.expiresAt && resetToken.expiresAt < new Date()) || resetToken.usedAt) {
throw createError({ statusCode: 400, statusMessage: 'Dieser Link ist ungültig oder abgelaufen' })
throw createError({ statusCode: 400, statusMessage: 'This link is invalid or has expired' })
}
const user = await User.findById(resetToken.user)
if (!user) {
await resetToken.deleteOne().catch(() => undefined)
throw createError({ statusCode: 400, statusMessage: 'Dieser Link ist ungültig oder abgelaufen' })
throw createError({ statusCode: 400, statusMessage: 'This link is invalid or has expired' })
}
user.passwordHash = await hashPassword(password)

View File

@@ -7,11 +7,11 @@ const CREATION_DEADLINE = new Date(process.env.BOOTSTRAP_INVITE_DEADLINE ?? '202
export default defineEventHandler(async (event) => {
const now = new Date()
if (Number.isNaN(CREATION_DEADLINE.getTime())) {
throw createError({ statusCode: 500, statusMessage: 'Konfiguration für Bootstrap-Deadline ungültig' })
throw createError({ statusCode: 500, statusMessage: 'Invalid bootstrap deadline configuration' })
}
if (now > CREATION_DEADLINE) {
throw createError({ statusCode: 403, statusMessage: 'Bootstrap-Zeitraum abgelaufen' })
throw createError({ statusCode: 403, statusMessage: 'Bootstrap window has expired' })
}
const body = await readBody<{ label?: string }>(event).catch(() => ({ label: undefined }))

View File

@@ -31,14 +31,14 @@ export default defineEventHandler<ManualInviteResponse>(async (event) => {
const expectedPassword = (config.manualInvitePassword as string | undefined)?.trim() || ''
if (!expectedPassword) {
throw createError({ statusCode: 500, statusMessage: 'Konfiguration für manuellen Einladungscode fehlt' })
throw createError({ statusCode: 500, statusMessage: 'Manual invitation code configuration missing' })
}
const body = await readBody<ManualInviteRequestBody>(event).catch(() => ({}) as ManualInviteRequestBody)
const providedPassword = body.password?.trim() || ''
if (!providedPassword || !safeComparePassword(providedPassword, expectedPassword)) {
throw createError({ statusCode: 401, statusMessage: 'Ungültiges Passwort' })
throw createError({ statusCode: 401, statusMessage: 'Invalid password' })
}
const now = new Date()

View File

@@ -21,19 +21,19 @@ export default defineEventHandler(async (event) => {
const allowContact = Boolean(body.allowContact && email)
if (!title || title.length < MIN_TITLE_LENGTH) {
throw createError({ statusCode: 400, statusMessage: 'Bitte gib einen kurzen Titel (mindestens 4 Zeichen) an.' })
throw createError({ statusCode: 400, statusMessage: 'Please provide a short title (at least 4 characters).' })
}
if (!details || details.length < MIN_DETAILS_LENGTH) {
throw createError({ statusCode: 400, statusMessage: 'Beschreibe deinen Vorschlag mit mindestens 20 Zeichen.' })
throw createError({ statusCode: 400, statusMessage: 'Please describe your suggestion in at least 20 characters.' })
}
if (!body.consentPrivacy) {
throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung zur Datenschutzerklärung.' })
throw createError({ statusCode: 400, statusMessage: 'We need your consent to the privacy policy.' })
}
if (body.allowContact && !email) {
throw createError({ statusCode: 400, statusMessage: 'Bitte gib eine E-Mail an, damit wir dich kontaktieren können.' })
throw createError({ statusCode: 400, statusMessage: 'Please provide an email address so we can contact you.' })
}
const suggestion = await RoadmapSuggestion.create({
@@ -45,15 +45,15 @@ export default defineEventHandler(async (event) => {
})
const dataEntries = [
['Titel', title],
['Beschreibung', details],
['E-Mail', email || null],
['Kontaktaufnahme erlaubt', allowContact],
['Title', title],
['Description', details],
['Email', email || null],
['Contact allowed', allowContact],
]
await sendAdminNotification({
event: 'Neuer Roadmap-Vorschlag',
summary: `Neuer Roadmap-Vorschlag: ${title}`,
event: 'New roadmap suggestion',
summary: `New roadmap suggestion: ${title}`,
data: dataEntries,
})

View File

@@ -20,11 +20,11 @@ export default defineEventHandler(async (event) => {
const body = await readBody<{ votes?: RoadmapVotePayload[] }>(event)
if (!body?.votes || !Array.isArray(body.votes) || body.votes.length === 0) {
throw createError({ statusCode: 400, statusMessage: 'Keine Stimmen übermittelt' })
throw createError({ statusCode: 400, statusMessage: 'No votes submitted' })
}
if (body.votes.length > MAX_VOTES_PER_SUBMISSION) {
throw createError({ statusCode: 400, statusMessage: 'Zu viele Stimmen in einer Anfrage' })
throw createError({ statusCode: 400, statusMessage: 'Too many votes in a single request' })
}
const normalized = new Map<string, number>()
@@ -35,17 +35,17 @@ export default defineEventHandler(async (event) => {
}
const key = String(vote.key ?? '').trim()
if (!ROADMAP_ITEM_KEYS.has(key)) {
throw createError({ statusCode: 400, statusMessage: `Unbekannter Roadmap-Eintrag: ${key || '?'}` })
throw createError({ statusCode: 400, statusMessage: `Unknown roadmap entry: ${key || '?'}` })
}
const importance = Math.round(Number(vote.importance ?? 0))
if (!Number.isFinite(importance) || importance < 1 || importance > 5) {
throw createError({ statusCode: 400, statusMessage: 'Bewertung muss zwischen 1 und 5 liegen' })
throw createError({ statusCode: 400, statusMessage: 'Rating must be between 1 and 5' })
}
normalized.set(key, importance)
}
if (normalized.size === 0) {
throw createError({ statusCode: 400, statusMessage: 'Keine gültigen Stimmen gefunden' })
throw createError({ statusCode: 400, statusMessage: 'No valid votes found' })
}
const clientHash = buildClientHash(event)

View File

@@ -17,15 +17,15 @@ export default defineEventHandler(async (event) => {
const source = body.source?.trim() || 'landing-updates'
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
throw createError({ statusCode: 400, statusMessage: 'Email is required' })
}
if (!body.consentPrivacy) {
throw createError({ statusCode: 400, statusMessage: 'Bitte bestätige die Datenschutzerklärung' })
throw createError({ statusCode: 400, statusMessage: 'Please confirm the privacy policy' })
}
if (!body.consentMarketing) {
throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung für Produkt-Updates per E-Mail' })
throw createError({ statusCode: 400, statusMessage: 'We need your consent to email you product updates' })
}
const result = await registerUpdateSubscriber({
@@ -38,13 +38,13 @@ export default defineEventHandler(async (event) => {
if (result.created) {
const dataEntries = [
['E-Mail', email],
['Email', email],
['Name', name || null],
['Quelle', source],
['Source', source],
]
await sendAdminNotification({
event: 'Neue Updates-Liste',
summary: `Neue Updates-Anmeldung: ${email}`,
event: 'New updates signup',
summary: `New updates signup: ${email}`,
data: dataEntries,
})
}

View File

@@ -22,11 +22,11 @@ export default defineEventHandler(async (event) => {
const wantsProductUpdates = Boolean(body.wantsProductUpdates)
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
throw createError({ statusCode: 400, statusMessage: 'Email is required' })
}
if (!body.consentPrivacy || !body.consentTerms) {
throw createError({ statusCode: 400, statusMessage: 'Zustimmung zu AGB und Datenschutz ist erforderlich' })
throw createError({ statusCode: 400, statusMessage: 'Accepting the terms and privacy policy is required' })
}
const now = new Date()
@@ -57,20 +57,20 @@ export default defineEventHandler(async (event) => {
if (!previouslyWantedUpdates && updateResult.created) {
const dataEntries = [
['E-Mail', email],
['Email', email],
]
if (name) {
dataEntries.push(['Name', name])
}
if (notes) {
dataEntries.push(['Notizen', notes])
dataEntries.push(['Notes', notes])
}
dataEntries.push(['Quelle', source])
dataEntries.push(['Opt-In', 'Produkt-Updates'])
dataEntries.push(['Source', source])
dataEntries.push(['Opt-in', 'Product updates'])
await sendAdminNotification({
event: 'Neue Updates-Liste (via Warteliste)',
summary: `Produkt-Updates Opt-in (Warteliste): ${email}`,
event: 'New updates signup (waitlist)',
summary: `Product updates opt-in (waitlist): ${email}`,
data: dataEntries,
})
}
@@ -106,20 +106,20 @@ export default defineEventHandler(async (event) => {
}
const dataEntries = [
['E-Mail', email],
['Email', email],
]
if (name) {
dataEntries.push(['Name', name])
}
if (notes) {
dataEntries.push(['Notizen', notes])
dataEntries.push(['Notes', notes])
}
dataEntries.push(['Quelle', source])
dataEntries.push(['Opt-In', wantsProductUpdates ? 'Produkt-Updates' : 'Nur Warteliste'])
dataEntries.push(['Source', source])
dataEntries.push(['Opt-in', wantsProductUpdates ? 'Product updates' : 'Waitlist only'])
await sendAdminNotification({
event: 'Neue Wartelisten-Anmeldung',
summary: `Neue Wartelisten-Anmeldung: ${email}`,
event: 'New waitlist signup',
summary: `New waitlist signup: ${email}`,
data: dataEntries,
})