Files
Pickup-Config/services/foodsharingClient.js
Meik bd41e8c079 chore: sync running docker state
Commit the app code currently running in Docker, including scheduler, store status, journal reminder, maintenance mode, and Foodsharing API check updates. Remove generated runtime logs from version control and ignore local runtime captures.
2026-06-14 21:21:07 +02:00

561 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const axios = require('axios');
const http = require('http');
const https = require('https');
const requestLogStore = require('./requestLogStore');
const sessionStore = require('./sessionStore');
const maintenanceMode = require('./maintenanceMode');
const BASE_URL = 'https://foodsharing.de';
const keepAliveHttpAgent = new http.Agent({ keepAlive: true, maxSockets: 10 });
const keepAliveHttpsAgent = new https.Agent({ keepAlive: true, maxSockets: 10 });
const client = axios.create({
baseURL: BASE_URL,
timeout: 20000,
httpAgent: keepAliveHttpAgent,
httpsAgent: keepAliveHttpsAgent,
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 || {};
if (!metadata.skipRequestLog) {
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 || {};
let logEntry = null;
if (!metadata.skipRequestLog) {
try {
logEntry = 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);
}
try {
maintenanceMode.recordOutboundFailure(logEntry || {
direction: 'outgoing',
target: 'foodsharing.de',
status: error?.response?.status || null,
error: error?.message || 'Unbekannter Fehler',
timestamp: Date.now()
});
} catch (maintenanceError) {
console.warn('[MAINTENANCE] Fehlerauswertung fehlgeschlagen:', maintenanceError.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/users/current/details',
buildRequestConfig({ cookieHeader, context })
);
return options.raw ? response : response.data;
}
async function login(email, password) {
const payload = {
email,
password,
code: '',
rememberMe: true
};
const headers = {
Origin: BASE_URL,
Referer: BASE_URL,
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
'Content-Type': 'application/json; charset=utf-8'
};
const response = await client.post('/api/login', payload, { headers });
const cookies = response.headers['set-cookie'] || [];
const csrfToken = extractCsrfToken(cookies);
let cookieHeader = mergeCookieHeaders('', 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));
}
function hasRegularStoreAccess(store) {
if (!store) {
return false;
}
if (store.isManaging) {
return true;
}
const membershipStatus = Number(store.membershipStatus);
if (!Number.isFinite(membershipStatus)) {
return true;
}
return membershipStatus === 1;
}
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/users/${profileId}/stores`,
buildRequestConfig({ cookieHeader, params: { excludeInactive: true }, 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.zipCode || 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;
if (!hasRegularStoreAccess(store)) {
hasPickupSlots = false;
console.info(
`Pickups für Store ${store.id} werden wegen Mitgliedschaftsstatus ${store.membershipStatus} nicht abgefragt.`
);
annotated.push({ ...store, hasPickupSlots });
continue;
}
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 })
);
if (Array.isArray(response.data)) {
return response.data;
}
return response.data?.pickups || [];
}
async function fetchRegionStores(regionId, cookieHeader, context) {
if (!regionId) {
return { total: 0, stores: [] };
}
const response = await client.get(
`/api/regions/${regionId}/stores`,
buildRequestConfig({ cookieHeader, context })
);
const stores = Array.isArray(response.data) ? response.data : Array.isArray(response.data?.stores) ? response.data.stores : [];
return {
total: Number(response.data?.total) || stores.length,
stores
};
}
async function fetchStoreMarker(storeId, cookieHeader, context) {
if (!storeId) {
return null;
}
const response = await client.get(
`/api/map/markers/stores/${storeId}`,
buildRequestConfig({ cookieHeader, context })
);
return response.data || null;
}
async function pickupRuleCheck(storeId, utcDate, profileId, session) {
const response = await client.get(
`/api/stores/${storeId}/pickups/${utcDate}/eligibility`,
buildRequestConfig({
cookieHeader: session.cookieHeader,
csrfToken: session.csrfToken,
context: session
})
);
return response.data?.isEligible === true || response.data?.result === true;
}
async function fetchStoreMembers(storeId, cookieHeader, context) {
if (!storeId) {
return [];
}
const response = await client.get(
`/api/stores/${storeId}/members`,
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}/regular-pickups`,
buildRequestConfig({ cookieHeader, context })
);
return Array.isArray(response.data) ? response.data : [];
}
async function fetchRegisteredPickups(userId, cookieHeader, context) {
const normalizedUserId = userId ? String(userId) : 'current';
const response = await client.get(
`/api/users/${normalizedUserId}/pickups/registered`,
buildRequestConfig({ cookieHeader, context })
);
return Array.isArray(response.data) ? response.data : [];
}
async function fetchConversations(cookieHeader, options = {}, context) {
const limit = Math.max(1, Math.min(Number(options.limit) || 20, 100));
const offset = Math.max(0, Number(options.offset) || 0);
const config = buildRequestConfig({ cookieHeader, params: { limit, offset }, context });
config.metadata = { ...(config.metadata || {}), skipRequestLog: true };
const response = await client.get('/api/conversations', config);
const conversations = Array.isArray(response.data?.conversations)
? response.data.conversations
: Array.isArray(response.data)
? response.data
: [];
return {
conversations,
profiles: Array.isArray(response.data?.profiles) ? response.data.profiles : []
};
}
async function bookSlot(storeId, utcDate, profileId, session) {
await client.post(
`/api/stores/${storeId}/pickups/${utcDate}/users/current`,
{},
{
...buildRequestConfig({
cookieHeader: session.cookieHeader,
csrfToken: session.csrfToken,
context: session
})
}
);
}
module.exports = {
login,
fetchProfile,
fetchStores,
fetchPickups,
fetchRegionStores,
fetchStoreMarker,
fetchStoreMembers,
fetchRegularPickup,
fetchRegisteredPickups,
fetchConversations,
pickupRuleCheck,
bookSlot
};