const express = require('express'); const path = require('path'); const cors = require('cors'); const { v4: uuid } = require('uuid'); const sessionStore = require('./services/sessionStore'); const credentialStore = require('./services/credentialStore'); const { readConfig, writeConfig } = require('./services/configStore'); const foodsharingClient = require('./services/foodsharingClient'); const { scheduleConfig } = require('./services/pickupScheduler'); const adminConfig = require('./services/adminConfig'); const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase(); const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000; const app = express(); const port = process.env.PORT || 3000; const storeRefreshJobs = new Map(); const cachedStoreSnapshots = new Map(); app.use(cors()); app.use(express.json({ limit: '1mb' })); app.use(express.static(path.join(__dirname, 'build'))); function isAdmin(profile) { if (!adminEmail || !profile?.email) { return false; } return profile.email.toLowerCase() === adminEmail; } function scheduleWithCurrentSettings(sessionId, config) { const settings = adminConfig.readSettings(); scheduleConfig(sessionId, config, settings); } function rescheduleAllSessions() { const settings = adminConfig.readSettings(); sessionStore.list().forEach((session) => { if (!session?.profile?.id) { return; } const config = readConfig(session.profile.id); scheduleConfig(session.id, config, settings); }); } function mergeStoresIntoConfig(config = [], stores = []) { const entries = Array.isArray(config) ? config : []; const map = new Map(); entries.forEach((entry) => { if (!entry || !entry.id) { return; } map.set(String(entry.id), { ...entry, id: String(entry.id) }); }); let changed = false; stores.forEach((store) => { if (!store?.id) { return; } const id = String(store.id); if (!map.has(id)) { const hideByDefault = store.hasPickupSlots === false; map.set(id, { id, label: store.name || `Store ${id}`, active: false, checkProfileId: true, onlyNotify: false, hidden: hideByDefault }); changed = true; return; } const existing = map.get(id); if (!existing.label && store.name) { existing.label = store.name; changed = true; } }); return { merged: Array.from(map.values()), changed }; } function isStoreCacheFresh(session) { if (!session?.storesCache?.fetchedAt) { return false; } return Date.now() - session.storesCache.fetchedAt <= SIXTY_DAYS_MS; } function summarizeJob(job) { if (!job) { return null; } return { id: job.id, status: job.status, processed: job.processed, total: job.total, currentStore: job.currentStore, startedAt: job.startedAt, finishedAt: job.finishedAt, reason: job.reason || null, error: job.error || null }; } function getStoreRefreshJob(sessionId) { const job = storeRefreshJobs.get(sessionId); if (!job) { return null; } if (job.status === 'done' && Date.now() - (job.finishedAt || 0) > 5 * 60 * 1000) { storeRefreshJobs.delete(sessionId); return null; } return job; } function triggerStoreRefresh(session, { force = false, reason } = {}) { if (!session?.id) { return { started: false }; } const existing = getStoreRefreshJob(session.id); if (existing && existing.status === 'running') { return { started: false, job: existing }; } const cacheFresh = isStoreCacheFresh(session); if (!force && cacheFresh) { return { started: false, cacheFresh: true }; } const job = { id: uuid(), status: 'queued', processed: 0, total: 0, currentStore: null, startedAt: null, finishedAt: null, reason: reason || null, error: null }; storeRefreshJobs.set(session.id, job); runStoreRefreshJob(session, job).catch((error) => { console.error('[STORE-REFRESH] Job fehlgeschlagen:', error.message); job.status = 'error'; job.error = error.message; job.finishedAt = Date.now(); }); return { started: true, job }; } async function runStoreRefreshJob(session, job) { job.status = 'running'; job.startedAt = Date.now(); const settings = adminConfig.readSettings(); const stores = await foodsharingClient.fetchStores(session.cookieHeader, session.profile.id, { delayBetweenRequestsMs: settings.storePickupCheckDelayMs, onStoreCheck: (store, processed, total) => { job.processed = processed; job.total = total; job.currentStore = store.name || `Store ${store.id}`; } }); job.processed = stores.length; job.total = stores.length; job.currentStore = null; sessionStore.update(session.id, { storesCache: { data: stores, fetchedAt: Date.now() } }); let config = readConfig(session.profile.id); const { merged, changed } = mergeStoresIntoConfig(config, stores); if (changed) { config = merged; writeConfig(session.profile.id, config); scheduleWithCurrentSettings(session.id, config); } job.status = 'done'; job.finishedAt = Date.now(); } async function loadStoresForSession(session, _settings, { forceRefresh = false, reason } = {}) { if (!session?.profile?.id) { return { stores: [], refreshed: false }; } const cacheFresh = isStoreCacheFresh(session); if ((forceRefresh || !cacheFresh) && session.cookieHeader) { triggerStoreRefresh(session, { force: true, reason: reason || 'session-check' }); } return { stores: session.storesCache?.data || [], refreshed: cacheFresh }; } async function restoreSessionsFromDisk() { const saved = credentialStore.loadAll(); const entries = Object.entries(saved); if (entries.length === 0) { return; } console.log(`[RESTORE] Versuche ${entries.length} gespeicherte Anmeldung(en) zu laden...`); const schedulerSettings = adminConfig.readSettings(); for (const [profileId, credentials] of entries) { if (!credentials?.email || !credentials?.password) { continue; } try { const auth = await foodsharingClient.login(credentials.email, credentials.password); const profile = { id: String(auth.profile.id), name: auth.profile.name, email: auth.profile.email || credentials.email }; const isAdminUser = isAdmin(profile); let config = readConfig(profile.id); const stores = await foodsharingClient.fetchStores(auth.cookieHeader, profile.id, { delayBetweenRequestsMs: schedulerSettings.storePickupCheckDelayMs }); const { merged, changed } = mergeStoresIntoConfig(config, stores); if (changed) { config = merged; writeConfig(profile.id, config); } const session = sessionStore.create({ cookieHeader: auth.cookieHeader, csrfToken: auth.csrfToken, profile, credentials, isAdmin: isAdminUser }, credentials.token, ONE_YEAR_MS); credentialStore.save(profile.id, { email: credentials.email, password: credentials.password, token: session.id }); sessionStore.update(session.id, { storesCache: { data: stores, fetchedAt: Date.now() } }); scheduleConfig(session.id, config, schedulerSettings); console.log(`[RESTORE] Session fuer Profil ${profile.id} (${profile.name}) reaktiviert.`); } catch (error) { console.error(`[RESTORE] Login fuer Profil ${profileId} fehlgeschlagen:`, error.message); } } } function requireAuth(req, res, next) { const header = req.headers.authorization || ''; const [scheme, token] = header.split(' '); if (scheme !== 'Bearer' || !token) { return res.status(401).json({ error: 'Unautorisiert' }); } const session = sessionStore.get(token); if (!session) { return res.status(401).json({ error: 'Session nicht gefunden oder abgelaufen' }); } req.session = session; next(); } function requireAdmin(req, res, next) { if (!req.session?.isAdmin) { return res.status(403).json({ error: 'Nur für Admins verfügbar' }); } next(); } app.post('/api/auth/login', async (req, res) => { const { email, password } = req.body || {}; if (!email || !password) { return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' }); } try { const auth = await foodsharingClient.login(email, password); const profile = { id: String(auth.profile.id), name: auth.profile.name, email: auth.profile.email || email }; const isAdminUser = isAdmin(profile); const settings = adminConfig.readSettings(); let config = readConfig(profile.id); const existingCredentials = credentialStore.get(profile.id); const existingToken = existingCredentials?.token; const previousSession = existingToken ? sessionStore.get(existingToken) : null; if (existingToken) { sessionStore.delete(existingToken); } const session = sessionStore.create({ cookieHeader: auth.cookieHeader, csrfToken: auth.csrfToken, profile, credentials: { email, password }, isAdmin: isAdminUser }, existingToken, ONE_YEAR_MS); credentialStore.save(profile.id, { email, password, token: session.id }); scheduleConfig(session.id, config, settings); if (previousSession?.storesCache) { sessionStore.update(session.id, { storesCache: previousSession.storesCache }); } else if (cachedStoreSnapshots.has(profile.id)) { sessionStore.update(session.id, { storesCache: cachedStoreSnapshots.get(profile.id) }); cachedStoreSnapshots.delete(profile.id); } const currentSession = sessionStore.get(session.id); const needsRefresh = !isStoreCacheFresh(currentSession); const refreshResult = needsRefresh ? triggerStoreRefresh(currentSession, { force: true, reason: 'login' }) : {}; return res.json({ token: session.id, profile, stores: sessionStore.get(session.id)?.storesCache?.data || [], config, isAdmin: isAdminUser, adminSettings: isAdminUser ? settings : undefined, storeRefreshJob: summarizeJob(refreshResult?.job), storesFresh: isStoreCacheFresh(sessionStore.get(session.id)) }); } catch (error) { console.error('Login fehlgeschlagen:', error.message); return res.status(401).json({ error: 'Login fehlgeschlagen' }); } }); app.post('/api/auth/logout', requireAuth, (req, res) => { if (req.session?.profile?.id && req.session?.storesCache) { cachedStoreSnapshots.set(req.session.profile.id, req.session.storesCache); } sessionStore.delete(req.session.id); credentialStore.remove(req.session.profile.id); storeRefreshJobs.delete(req.session.id); res.json({ success: true }); }); app.get('/api/auth/session', requireAuth, async (req, res) => { const settings = adminConfig.readSettings(); const { stores } = await loadStoresForSession(req.session, settings, { reason: 'session-check' }); res.json({ profile: req.session.profile, stores, isAdmin: !!req.session.isAdmin, adminSettings: req.session.isAdmin ? settings : undefined, storeRefreshJob: summarizeJob(getStoreRefreshJob(req.session.id)), storesFresh: isStoreCacheFresh(req.session) }); }); app.get('/api/profile', requireAuth, async (req, res) => { const details = await foodsharingClient.fetchProfile(req.session.cookieHeader); res.json({ profile: details || req.session.profile }); }); app.get('/api/config', requireAuth, (req, res) => { const config = readConfig(req.session.profile.id); res.json(config); }); app.post('/api/config', requireAuth, (req, res) => { if (!Array.isArray(req.body)) { return res.status(400).json({ error: 'Konfiguration muss ein Array sein' }); } writeConfig(req.session.profile.id, req.body); scheduleWithCurrentSettings(req.session.id, req.body); res.json({ success: true }); }); app.get('/api/stores', requireAuth, async (req, res) => { res.json(req.session.storesCache?.data || []); }); app.post('/api/stores/refresh', requireAuth, (req, res) => { const force = req.body?.force !== undefined ? !!req.body.force : true; const reason = req.body?.reason || 'manual'; const result = triggerStoreRefresh(req.session, { force, reason }); res.json({ started: !!result.started, storesFresh: isStoreCacheFresh(req.session), job: summarizeJob(result.job) }); }); app.get('/api/stores/refresh/status', requireAuth, (req, res) => { const job = getStoreRefreshJob(req.session.id); res.json({ job: summarizeJob(job), storesFresh: isStoreCacheFresh(req.session), cacheUpdatedAt: req.session.storesCache?.fetchedAt || null }); }); app.get('/api/admin/settings', requireAuth, requireAdmin, (_req, res) => { res.json(adminConfig.readSettings()); }); app.post('/api/admin/settings', requireAuth, requireAdmin, (req, res) => { const updated = adminConfig.writeSettings(req.body || {}); rescheduleAllSessions(); res.json(updated); }); app.get('/api/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'build', 'index.html')); }); async function startServer() { try { await restoreSessionsFromDisk(); } catch (error) { console.error('[RESTORE] Fehler bei der Session-Wiederherstellung:', error.message); } app.listen(port, () => { console.log(`Server läuft auf Port ${port}`); }); } startServer().catch((error) => { console.error('[STARTUP] Unerwarteter Fehler:', error); process.exit(1); });