491 lines
15 KiB
JavaScript
491 lines
15 KiB
JavaScript
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 { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
|
|
const notificationService = require('./services/notificationService');
|
|
|
|
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 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: sessionStore.get(session.id)?.storesCache || null
|
|
});
|
|
scheduleConfig(session.id, config, schedulerSettings);
|
|
triggerStoreRefresh(sessionStore.get(session.id), { force: true, reason: 'restore' });
|
|
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);
|
|
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/notifications/settings', requireAuth, (req, res) => {
|
|
const userSettings = readNotificationSettings(req.session.profile.id);
|
|
const adminSettings = adminConfig.readSettings();
|
|
res.json({
|
|
settings: userSettings.notifications,
|
|
capabilities: {
|
|
ntfy: {
|
|
enabled: !!(
|
|
adminSettings.notifications?.ntfy?.enabled && adminSettings.notifications?.ntfy?.serverUrl
|
|
),
|
|
serverUrl: adminSettings.notifications?.ntfy?.serverUrl || '',
|
|
topicPrefix: adminSettings.notifications?.ntfy?.topicPrefix || ''
|
|
},
|
|
telegram: {
|
|
enabled: !!(
|
|
adminSettings.notifications?.telegram?.enabled && adminSettings.notifications?.telegram?.botToken
|
|
)
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
app.post('/api/notifications/settings', requireAuth, (req, res) => {
|
|
const payload = {
|
|
notifications: {
|
|
ntfy: {
|
|
enabled: !!req.body?.notifications?.ntfy?.enabled,
|
|
topic: req.body?.notifications?.ntfy?.topic || '',
|
|
serverUrl: req.body?.notifications?.ntfy?.serverUrl || ''
|
|
},
|
|
telegram: {
|
|
enabled: !!req.body?.notifications?.telegram?.enabled,
|
|
chatId: req.body?.notifications?.telegram?.chatId || ''
|
|
}
|
|
}
|
|
};
|
|
const updated = writeNotificationSettings(req.session.profile.id, payload);
|
|
res.json(updated.notifications);
|
|
});
|
|
|
|
app.post('/api/notifications/test', requireAuth, async (req, res) => {
|
|
try {
|
|
await notificationService.sendTestNotification(req.session.profile.id, req.body?.channel);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(400).json({ error: error.message || 'Testbenachrichtigung fehlgeschlagen' });
|
|
}
|
|
});
|
|
|
|
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);
|
|
});
|