fix(dev): Vite-API-Proxy, Auth, Stripe-Mails und Backend-Erweiterungen

- Client: API-Basis-URL (joinApiUrl, /v1-Falle), Vite strictPort + Proxy 127.0.0.1, Nicht-JSON-Fehler

- Server: /api-404 ohne Wildcard-Bug, SPA-Fallback, Auth-Middleware, Cron, Mailer, Crypto

- Routen: OAuth-State, Email/Stripe/Analytics; client/.env.example

Made-with: Cursor
This commit is contained in:
2026-04-03 00:23:01 +02:00
parent 61008b63bb
commit ecae89a79d
33 changed files with 1663 additions and 550 deletions

74
server/utils/crypto.mjs Normal file
View File

@@ -0,0 +1,74 @@
/**
* AES-256-GCM for IMAP passwords. ENCRYPTION_KEY = 64 hex chars (32 bytes).
* Legacy: if decrypt fails or key missing, value treated as plaintext.
*/
import crypto from 'crypto'
const ALGO = 'aes-256-gcm'
const IV_LEN = 16
const AUTH_TAG_LEN = 16
function getKeyBuffer() {
const hex = process.env.ENCRYPTION_KEY || ''
if (hex.length !== 64) {
throw new Error('ENCRYPTION_KEY must be 64 hex characters (32 bytes). Generate: openssl rand -hex 32')
}
return Buffer.from(hex, 'hex')
}
export function encrypt(text) {
if (text == null || text === '') return ''
const key = getKeyBuffer()
const iv = crypto.randomBytes(IV_LEN)
const cipher = crypto.createCipheriv(ALGO, key, iv, { authTagLength: AUTH_TAG_LEN })
const enc = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
const combined = Buffer.concat([iv, authTag, enc])
return combined.toString('base64url')
}
export function decrypt(encoded) {
if (!encoded) return ''
const buf = Buffer.from(String(encoded), 'base64url')
if (buf.length < IV_LEN + AUTH_TAG_LEN + 1) {
throw new Error('invalid ciphertext')
}
const key = getKeyBuffer()
const iv = buf.subarray(0, IV_LEN)
const authTag = buf.subarray(IV_LEN, IV_LEN + AUTH_TAG_LEN)
const data = buf.subarray(IV_LEN + AUTH_TAG_LEN)
const decipher = crypto.createDecipheriv(ALGO, key, iv, { authTagLength: AUTH_TAG_LEN })
decipher.setAuthTag(authTag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
}
/** Encrypt IMAP password when ENCRYPTION_KEY is set; otherwise store plaintext. */
export function encryptImapSecret(plain) {
if (plain == null || plain === '') return ''
if (!process.env.ENCRYPTION_KEY) return String(plain)
try {
return encrypt(plain)
} catch (e) {
logWarnOnce('encryptImapSecret', e.message)
return String(plain)
}
}
/** Decrypt IMAP secret; on failure return as plaintext (legacy). */
export function decryptImapSecret(stored) {
if (stored == null || stored === '') return ''
if (!process.env.ENCRYPTION_KEY) return String(stored)
try {
return decrypt(stored)
} catch {
return String(stored)
}
}
let warnedEncrypt = false
function logWarnOnce(tag, msg) {
if (warnedEncrypt) return
warnedEncrypt = true
console.warn(`[crypto] ${tag}: ${msg}`)
}

68
server/utils/mailer.mjs Normal file
View File

@@ -0,0 +1,68 @@
/**
* Plain SMTP mailer (nodemailer). Optional: if SMTP not configured, send is a no-op.
*/
import nodemailer from 'nodemailer'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { log } from '../middleware/logger.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
let transporter = null
function getTransporter() {
const host = process.env.SMTP_HOST
const user = process.env.SMTP_USER
const pass = process.env.SMTP_PASS
if (!host || !user || !pass) {
return null
}
if (!transporter) {
transporter = nodemailer.createTransport({
host,
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true',
auth: { user, pass },
})
}
return transporter
}
export function renderTemplate(text, vars) {
let out = text
for (const [k, v] of Object.entries(vars || {})) {
out = out.split(`{{${k}}}`).join(v != null ? String(v) : '')
}
return out
}
export function loadEmailTemplate(name) {
const path = join(__dirname, '..', 'emails', `${name}.txt`)
return readFileSync(path, 'utf8')
}
/**
* Send plain-text email. Returns false if SMTP not configured or send failed (logged).
*/
export async function sendPlainEmail({ to, subject, text }) {
const from = process.env.SMTP_FROM || process.env.SMTP_USER
if (!to || !subject || !text) {
log.warn('sendPlainEmail: missing to/subject/text')
return false
}
const tx = getTransporter()
if (!tx) {
log.warn('SMTP not configured (SMTP_HOST/SMTP_USER/SMTP_PASS); email skipped')
return false
}
try {
await tx.sendMail({ from, to, subject, text })
log.info(`Email sent to ${to}: ${subject}`)
return true
} catch (e) {
log.error('sendPlainEmail failed', { error: e.message, to })
return false
}
}

View File

@@ -0,0 +1,44 @@
/**
* Signed OAuth state (userId) to prevent tampering when OAUTH_STATE_SECRET is set.
*/
import crypto from 'crypto'
import { config } from '../config/index.mjs'
export function buildOAuthState(userId) {
const secret = config.oauthStateSecret
if (!secret) {
return JSON.stringify({ userId })
}
const body = JSON.stringify({ userId, exp: Date.now() + 15 * 60 * 1000 })
const sig = crypto.createHmac('sha256', secret).update(body).digest('hex')
return Buffer.from(JSON.stringify({ b: body, s: sig })).toString('base64url')
}
export function parseOAuthState(state) {
if (!state || typeof state !== 'string') {
throw new Error('invalid_state')
}
const trimmed = state.trim()
const secret = config.oauthStateSecret
if (trimmed.startsWith('{')) {
const legacy = JSON.parse(trimmed)
if (!legacy.userId) throw new Error('invalid_state')
if (secret) {
throw new Error('unsigned_state_rejected')
}
return { userId: legacy.userId }
}
const raw = Buffer.from(trimmed, 'base64url').toString('utf8')
const outer = JSON.parse(raw)
if (!outer.b || !outer.s) throw new Error('invalid_state')
if (!secret) throw new Error('signed_state_requires_secret')
const expected = crypto.createHmac('sha256', secret).update(outer.b).digest('hex')
if (outer.s !== expected) throw new Error('invalid_state_signature')
const payload = JSON.parse(outer.b)
if (payload.exp != null && payload.exp < Date.now()) throw new Error('state_expired')
if (!payload.userId) throw new Error('invalid_state')
return { userId: payload.userId }
}