const axios = require('axios'); const requestLogStore = require('./requestLogStore'); const sessionStore = require('./sessionStore'); const BASE_URL = 'https://foodsharing.de'; const client = axios.create({ baseURL: BASE_URL, timeout: 20000, headers: { 'User-Agent': 'pickup-config/1.0 (+https://foodsharing.de)', Accept: 'application/json, text/plain, */*' } }); client.interceptors.request.use((config) => { const metadata = config.metadata && typeof config.metadata === 'object' ? config.metadata : {}; config.metadata = { ...metadata, startedAt: Date.now() }; return config; }); client.interceptors.response.use( (response) => { const startedAt = response?.config?.metadata?.startedAt || Date.now(); const metadata = response?.config?.metadata || {}; try { requestLogStore.add({ direction: 'outgoing', target: 'foodsharing.de', method: (response.config?.method || 'GET').toUpperCase(), path: response.config?.url || '', status: response.status, durationMs: Date.now() - startedAt, sessionId: metadata.sessionId ?? null, profileId: metadata.profileId ?? null, profileName: metadata.profileName ?? null, responseBody: response.data }); } catch (error) { console.warn('[REQUEST-LOG] Outgoing-Log fehlgeschlagen:', error.message); } updateSessionCookiesFromResponse(response); return response; }, (error) => { const startedAt = error?.config?.metadata?.startedAt || Date.now(); const metadata = error?.config?.metadata || {}; try { requestLogStore.add({ direction: 'outgoing', target: 'foodsharing.de', method: (error.config?.method || 'GET').toUpperCase(), path: error.config?.url || '', status: error?.response?.status || null, durationMs: Date.now() - startedAt, sessionId: metadata.sessionId ?? null, profileId: metadata.profileId ?? null, profileName: metadata.profileName ?? null, error: error?.message || 'Unbekannter Fehler', responseBody: error?.response?.data }); } catch (logError) { console.warn('[REQUEST-LOG] Outgoing-Error-Log fehlgeschlagen:', logError.message); } if (error?.response) { updateSessionCookiesFromResponse(error.response); } return Promise.reject(error); } ); const CSRF_COOKIE_NAMES = ['FS_CSRF_TOKEN', 'CSRF_TOKEN', 'CSRF-TOKEN', 'XSRF-TOKEN', 'XSRF_TOKEN']; function extractCookieValue(cookies = [], name) { if (!Array.isArray(cookies) || !name) { return null; } const prefix = `${name}=`; const match = cookies.find((cookie) => cookie.startsWith(prefix)); if (!match) { return null; } return match.split(';')[0].slice(prefix.length); } function extractCsrfToken(cookies = []) { for (const name of CSRF_COOKIE_NAMES) { const value = extractCookieValue(cookies, name); if (value) { return value; } } return null; } function extractCsrfTokenFromCookieHeader(cookieHeader = '') { if (!cookieHeader) { return null; } const pairs = cookieHeader.split(';').map((part) => part.trim()); for (const name of CSRF_COOKIE_NAMES) { const prefix = `${name}=`; const match = pairs.find((pair) => pair.startsWith(prefix)); if (match) { return match.slice(prefix.length); } } return null; } function serializeCookies(cookies = []) { if (!Array.isArray(cookies)) { return ''; } return cookies.map((c) => c.split(';')[0]).join('; '); } function buildHeaders(cookieHeader, csrfToken) { const headers = {}; if (cookieHeader) { headers.cookie = cookieHeader; } const token = csrfToken || extractCsrfTokenFromCookieHeader(cookieHeader); if (token) { headers['x-csrf-token'] = token; headers['x-xsrf-token'] = token; } return headers; } function buildRequestMetadata(context) { if (!context) { return {}; } if (context.sessionId || context.profileId) { return { sessionId: context.sessionId ?? null, profileId: context.profileId ?? null, profileName: context.profileName ?? null }; } if (context.id || context.profile?.id) { return { sessionId: context.id ?? null, profileId: context.profile?.id ?? null, profileName: context.profile?.name ?? null }; } return {}; } function buildRequestConfig({ cookieHeader, csrfToken, context, params } = {}) { const metadata = buildRequestMetadata(context); const config = { headers: buildHeaders(cookieHeader, csrfToken) }; if (Object.keys(metadata).length > 0) { config.metadata = metadata; } if (params) { config.params = params; } return config; } function mergeCookieHeaders(existingHeader = '', setCookies = []) { const cookieMap = new Map(); if (existingHeader) { existingHeader.split(';').forEach((part) => { const trimmed = part.trim(); if (!trimmed) { return; } const [name, ...valueParts] = trimmed.split('='); if (!name) { return; } cookieMap.set(name, valueParts.join('=')); }); } if (Array.isArray(setCookies)) { setCookies.forEach((cookie) => { if (!cookie || typeof cookie !== 'string') { return; } const [nameValue] = cookie.split(';'); if (!nameValue) { return; } const [name, ...valueParts] = nameValue.split('='); if (!name) { return; } cookieMap.set(name, valueParts.join('=')); }); } return Array.from(cookieMap.entries()) .map(([name, value]) => `${name}=${value}`) .join('; '); } function updateSessionCookiesFromResponse(response) { const setCookies = response?.headers?.['set-cookie']; if (!Array.isArray(setCookies) || setCookies.length === 0) { return; } const metadata = response?.config?.metadata || {}; const sessionId = metadata.sessionId; if (!sessionId) { return; } const session = sessionStore.get(sessionId); if (!session) { return; } const mergedCookieHeader = mergeCookieHeaders(session.cookieHeader, setCookies); if (!mergedCookieHeader) { return; } const csrfToken = extractCsrfTokenFromCookieHeader(mergedCookieHeader) || session.csrfToken || null; sessionStore.update(sessionId, { cookieHeader: mergedCookieHeader, csrfToken }); } async function getCurrentUserDetails(cookieHeader, context, options = {}) { const response = await client.get( '/api/user/current/details', buildRequestConfig({ cookieHeader, context }) ); return options.raw ? response : response.data; } async function login(email, password) { const payload = { email, password, remember_me: true }; const headers = { 'sec-ch-ua': '"Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"', Referer: BASE_URL, DNT: '1', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"Linux"', 'Content-Type': 'application/json; charset=utf-8' }; const response = await client.post('/api/user/login', payload, { headers }); const cookies = response.headers['set-cookie'] || []; const csrfToken = extractCsrfToken(cookies); let cookieHeader = serializeCookies(cookies); const detailsResponse = await getCurrentUserDetails(cookieHeader, null, { raw: true }); const detailsCookies = detailsResponse?.headers?.['set-cookie'] || []; if (detailsCookies.length > 0) { cookieHeader = mergeCookieHeaders(cookieHeader, detailsCookies); } const updatedCsrf = extractCsrfTokenFromCookieHeader(cookieHeader) || csrfToken; const details = detailsResponse?.data; if (!details?.id) { throw new Error('Profil-ID konnte nicht ermittelt werden.'); } const nameParts = []; if (details.firstname) { nameParts.push(details.firstname); } if (details.lastname) { nameParts.push(details.lastname); } return { csrfToken: updatedCsrf, cookieHeader, profile: { id: String(details.id), name: nameParts.length > 0 ? nameParts.join(' ') : details.email || email, email: details.email || email } }; } async function fetchProfile(cookieHeader, { throwOnError = false } = {}, context) { try { return await getCurrentUserDetails(cookieHeader, context); } catch (error) { if (throwOnError) { throw error; } console.warn('Profil konnte nicht geladen werden:', error.message); return null; } } function wait(ms = 0) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function fetchStores(cookieHeader, profileId, options = {}, context) { if (!profileId) { return []; } const delayBetweenRequestsMs = Number.isFinite(options.delayBetweenRequestsMs) ? Math.max(0, options.delayBetweenRequestsMs) : 0; const onStoreCheck = typeof options.onStoreCheck === 'function' ? options.onStoreCheck : null; try { const response = await client.get( `/api/user/${profileId}/stores`, buildRequestConfig({ cookieHeader, params: { activeStores: 1 }, context }) ); const stores = Array.isArray(response.data) ? response.data : []; const normalized = stores.map((store) => ({ id: String(store.id), name: store.name || `Store ${store.id}`, pickupStatus: store.pickupStatus, membershipStatus: store.membershipStatus, isManaging: !!store.isManaging, city: store.city || '', street: store.street || '', zip: store.zip || '' })); return annotateStoresWithPickupSlots( normalized, cookieHeader, delayBetweenRequestsMs, onStoreCheck, context ); } catch (error) { console.warn('Stores konnten nicht geladen werden:', error.message); return []; } } async function annotateStoresWithPickupSlots( stores, cookieHeader, delayBetweenRequestsMs = 0, onStoreCheck, context ) { if (!Array.isArray(stores) || stores.length === 0) { return []; } const delayMs = Number.isFinite(delayBetweenRequestsMs) ? Math.max(0, delayBetweenRequestsMs) : 0; const annotated = []; for (let index = 0; index < stores.length; index += 1) { const store = stores[index]; if (onStoreCheck) { try { onStoreCheck(store, index + 1, stores.length); } catch (callbackError) { console.warn('Store-Progress-Callback fehlgeschlagen:', callbackError); } } if (delayMs > 0 && index > 0) { await wait(delayMs); } let hasPickupSlots = null; try { const pickups = await fetchPickups(store.id, cookieHeader, context); hasPickupSlots = Array.isArray(pickups) && pickups.length > 0; } catch (error) { const status = error?.response?.status; if (status === 403) { hasPickupSlots = false; console.warn(`Pickups für Store ${store.id} nicht erlaubt (403) – wird als ohne Slots behandelt.`); } else { console.warn(`Pickups für Store ${store.id} konnten nicht geprüft werden:`, error.message); } } annotated.push({ ...store, hasPickupSlots }); } return annotated; } async function fetchPickups(storeId, cookieHeader, context) { const response = await client.get( `/api/stores/${storeId}/pickups`, buildRequestConfig({ cookieHeader, context }) ); return response.data?.pickups || []; } async function fetchRegionStores(regionId, cookieHeader, context) { if (!regionId) { return { total: 0, stores: [] }; } const response = await client.get( `/api/region/${regionId}/stores`, buildRequestConfig({ cookieHeader, context }) ); return { total: Number(response.data?.total) || 0, stores: Array.isArray(response.data?.stores) ? response.data.stores : [] }; } async function fetchStoreDetails(storeId, cookieHeader, context) { if (!storeId) { return null; } const response = await client.get( `/api/map/stores/${storeId}`, buildRequestConfig({ cookieHeader, context }) ); return response.data || null; } async function pickupRuleCheck(storeId, utcDate, profileId, session) { const response = await client.get( `/api/stores/${storeId}/pickupRuleCheck/${utcDate}/${profileId}`, buildRequestConfig({ cookieHeader: session.cookieHeader, csrfToken: session.csrfToken, context: session }) ); return response.data?.result === true; } async function fetchStoreMembers(storeId, cookieHeader, context) { if (!storeId) { return []; } const response = await client.get( `/api/stores/${storeId}/member`, buildRequestConfig({ cookieHeader, context }) ); return Array.isArray(response.data) ? response.data : []; } async function fetchRegularPickup(storeId, cookieHeader, context) { if (!storeId) { return []; } const response = await client.get( `/api/stores/${storeId}/regularPickup`, buildRequestConfig({ cookieHeader, context }) ); return Array.isArray(response.data) ? response.data : []; } async function bookSlot(storeId, utcDate, profileId, session) { await client.post( `/api/stores/${storeId}/pickups/${utcDate}/${profileId}`, {}, { ...buildRequestConfig({ cookieHeader: session.cookieHeader, csrfToken: session.csrfToken, context: session }) } ); } module.exports = { login, fetchProfile, fetchStores, fetchPickups, fetchRegionStores, fetchStoreDetails, fetchStoreMembers, fetchRegularPickup, pickupRuleCheck, bookSlot };