470 lines
13 KiB
JavaScript
470 lines
13 KiB
JavaScript
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 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,
|
||
pickupRuleCheck,
|
||
bookSlot
|
||
};
|