const express = require('express'); const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const dns = require('dns'); const multer = require('multer'); // Minimal, dependency-free .env loader: load KEY=VALUE lines from a local // (git-ignored) .env file into process.env without overwriting existing vars. (function loadEnv() { try { const envPath = path.join(__dirname, '.env'); if (!fs.existsSync(envPath)) return; for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) { const line = raw.trim(); if (!line || line.startsWith('#')) continue; const eq = line.indexOf('='); if (eq === -1) continue; const key = line.slice(0, eq).trim(); let val = line.slice(eq + 1).trim(); if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } if (key && !(key in process.env)) process.env[key] = val; } } catch (e) { console.warn('Konnte .env nicht laden:', e.message); } })(); const { generateApplicationDocuments, generateEmailReply, generateFeinschliff, renderDesignVorschau, DOKUMENT_TYPEN, normalizeDokumente, standardDokumente, } = require('./lib/documents'); const chat = require('./lib/chat'); const websuche = require('./lib/websuche'); const promptStore = require('./lib/prompts'); const designStore = require('./lib/design'); const mailer = require('./lib/mailer'); const { createExternalApi } = require('./lib/api'); const { buildOpenApiSpec } = require('./lib/openapi'); const blacklist = require('./lib/blacklist'); const caldav = require('./lib/caldav'); const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./lib/labels'); const config = require('./lib/config'); const { userContext, currentUser, currentUserId } = require('./lib/context'); const password = require('./lib/password'); const migrate = require('./lib/migrate-multiuser'); const { runMigration, importEnvIntoAdmin } = migrate; const suchprofil = require('./lib/suchprofil'); const suche = require('./lib/suche'); const app = express(); const PORT = process.env.PORT || 3000; // Shared option lists (used in multiple views) const ART_OPTIONS = [ 'E-Mail', 'Online-Portal', 'Indeed', 'StepStone', 'Firmenwebsite', 'Post', 'Initiativbewerbung', 'Arbeitsagentur', 'Sonstiges' ]; const STATUS_OPTIONS = [ 'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', 'Vorstellungsgespräch', 'Vertragsverhandlung', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung' ]; // Base document types the user can provide as a foundation for AI tailoring const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges']; // Pick the application "source" (art) for a browser-captured job. Honour an // explicit value from the extension, otherwise infer it from the URL host so a // capture from any website is labelled sensibly. function deriveArt(url, provided) { if (provided && ART_OPTIONS.includes(provided)) return provided; const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase(); if (!host) return 'Sonstiges'; if (host.includes('indeed')) return 'Indeed'; if (host.includes('stepstone')) return 'StepStone'; if (host.includes('arbeitsagentur')) return 'Arbeitsagentur'; if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal'; return 'Firmenwebsite'; } // Trust the Caddy reverse proxy (TLS terminator) so req.secure / req.ip reflect // the real client connection — needed for the Secure cookie flag and rate limit. app.set('trust proxy', 1); // Don't advertise the framework version. app.disable('x-powered-by'); // Express ships with helpful dev-mode error pages; in production it never leaks // stack traces. We run behind Docker, so default to production unless overridden. app.set('env', process.env.NODE_ENV || 'production'); // --- Security headers ---------------------------------------------------- // No helmet dependency: a small middleware sets the headers that matter. The // CSP is intentionally permissive about *inline* scripts/styles (the views use a // lot of inline `); }); app.get('/api-docs', (req, res) => res.redirect(301, '/swagger')); // Start server app.listen(PORT, () => { console.log(`Server läuft auf http://localhost:${PORT}`); console.log('REST-API (/api/v1): pro Benutzer über X-API-Key (Token in den Einstellungen je Benutzer gesetzt) – Swagger unter /swagger'); }); // Background loops are per-user: every user owns their own mail/calendar // config, so polling iterates all users and runs each user's poll inside // that user's context (config.get() then resolves to the user's own values). async function forEachUser(fn) { const users = await dbAll('SELECT id, username, is_admin FROM users ORDER BY id'); for (const u of users) { try { await config.ensureLoaded(u.id); } catch (e) { continue; } try { await userContext.run(u, fn); } catch (e) { /* errors logged inside fn */ } } } async function pollInboxAllUsers() { await forEachUser(async () => { try { await pollInbox(); } catch (e) { /* logged inside pollInbox */ } }); } async function refreshCaldavAllUsers() { await forEachUser(async () => { try { await refreshCaldav(); } catch (e) { /* logged inside refreshCaldav */ } }); } // E-Mail: verify SMTP on startup (per-user, but verification only needs the // first configured user to confirm reachability) and poll every user's IMAP // inbox for replies on the configured interval. const pollMs = Math.max(60000, Number(config.get('MAIL_POLL_MS')) || 180000); setTimeout(() => { pollInboxAllUsers().catch(() => {}); }, 8000); // initial fetch after boot setInterval(() => { pollInboxAllUsers().catch(() => {}); }, pollMs); // periodic fetch // Calendar: reconcile every user's appointments with their CalDAV calendar. const calPoll = Math.max(60000, Number(config.get('CALDAV_POLL_MS')) || 300000); setTimeout(() => { refreshCaldavAllUsers().catch(() => {}); }, 10000); // initial sync after boot setInterval(() => { refreshCaldavAllUsers().catch(() => {}); }, calPoll); // periodic reconcile // Global error handler: never leak internals. API/AJAX callers get JSON, everyone // else gets a plain 500. Logged server-side with the stack for debugging. app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars if (res.headersSent) return next(err); console.error('Unhandled error:', err); const wantsJson = (req.get('accept') || '').includes('application/json') || req.path.startsWith('/api/') || req.xhr; if (wantsJson) return res.status(500).json({ error: 'Serverfehler' }); res.status(500).send('Serverfehler'); }); // Handle 404 app.use((req, res) => { res.status(404).send('Seite nicht gefunden'); }); }).catch((err) => { console.error('Failed to initialize database:', err); process.exit(1); }); // Close database on exit process.on('SIGINT', () => { db.close(); process.exit(); }); process.on('SIGTERM', () => { db.close(); process.exit(); });