From 9ff744399f4809e4e77c57529879d99b248f6e76 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 9 Jun 2026 18:45:09 -0400 Subject: [PATCH] Add apiv2 bridge router to server for legacy API integration --- inventory-server/src/routes/apiv2-bridge.js | 125 ++++++++ inventory-server/src/server.js | 5 + .../src/utils/acotBackendSession.js | 285 ++++++++++++++++++ 3 files changed, 415 insertions(+) create mode 100644 inventory-server/src/routes/apiv2-bridge.js create mode 100644 inventory-server/src/utils/acotBackendSession.js diff --git a/inventory-server/src/routes/apiv2-bridge.js b/inventory-server/src/routes/apiv2-bridge.js new file mode 100644 index 0000000..6e67ceb --- /dev/null +++ b/inventory-server/src/routes/apiv2-bridge.js @@ -0,0 +1,125 @@ +// apiv2-bridge — a SIDE-SERVICE that lets external apps post to the legacy PHP +// `/apiv2/*` API without a browser cookie. +// +// This is an ISOLATED, ADDITIVE router. It does not touch, wrap, or sit in the +// path of any existing flow: +// - The browser frontend keeps hitting Caddy's `/apiv2` -> PHP directly with +// its own session cookie. Unchanged. +// - This router is mounted at `/api/apiv2-bridge` (a deliberately distinct +// prefix that cannot be confused with the real `/apiv2`). It inherits the +// same user-JWT auth every `/api/*` route already enforces — the same token +// the product-import skill already sends. +// +// Flow: external app --(user JWT)--> /api/apiv2-bridge/ +// -> acotBackendSession swaps in a server-held PHP cookie +// -> POST backend.acherryontop.com/apiv2/ +// -> PHP JSON response relayed back verbatim. +// +// It is a constrained passthrough: only the known product/category/PO write +// surfaces are allowed, so a valid token can't be used to proxy arbitrary +// backend paths. + +import express from 'express'; +import { apiv2Request, checkSession, isHtmlResponse } from '../utils/acotBackendSession.js'; +import { logger } from '../../shared/logging/logger.js'; + +const router = express.Router(); + +// Allowed `/apiv2/<...>` path prefixes. Covers product edits/imports, category +// creation, and purchase-order creation — the surfaces the skills need. +const ALLOWED_PREFIXES = ['product/', 'prod_cat/', 'po/']; + +function isAllowed(subpath) { + return ALLOWED_PREFIXES.some((prefix) => subpath === prefix.slice(0, -1) || subpath.startsWith(prefix)); +} + +// Health/credential check: forces a fresh login and reports the result so an +// operator can validate ACOT_BACKEND_USERID/PIN without sending a real payload. +router.get('/_health', async (_req, res) => { + try { + const status = await checkSession(); + res.json({ ok: status.authenticated, ...status }); + } catch (err) { + logger.error({ err }, 'apiv2-bridge health check failed'); + res.status(502).json({ ok: false, error: err.message }); + } +}); + +// Reconstruct an outbound body + content-type from the already-parsed Express +// request. The global json/urlencoded parsers have consumed the raw stream, so +// we re-serialize from req.body to match what the caller sent. +function serializeBody(req) { + const incoming = (req.headers['content-type'] || '').toLowerCase(); + + if (incoming.includes('application/json')) { + return { contentType: 'application/json; charset=utf-8', body: JSON.stringify(req.body ?? {}) }; + } + + if (incoming.includes('application/x-www-form-urlencoded')) { + // The real payloads use flat string fields (e.g. `products`/`items` are + // already JSON strings), which round-trip cleanly through URLSearchParams. + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(req.body ?? {})) { + params.append(key, typeof value === 'string' ? value : JSON.stringify(value)); + } + return { contentType: 'application/x-www-form-urlencoded; charset=utf-8', body: params.toString() }; + } + + // No/own content-type: forward as-is (string or undefined). + return { contentType: incoming || undefined, body: req.body }; +} + +// Single passthrough handler for every allowed apiv2 subpath. The wildcard +// captures everything after the mount point (e.g. `product/setup_new`). +router.all('/*', async (req, res, next) => { + // `req.params[0]` is the wildcard capture; strip any leading slash. + const subpath = String(req.params[0] || '').replace(/^\/+/, ''); + + if (!subpath || subpath.startsWith('_')) return next(); + + if (!isAllowed(subpath)) { + return res.status(403).json({ + error: 'Path not allowed through the apiv2 bridge', + allowed: ALLOWED_PREFIXES, + }); + } + + // Preserve the original query string (e.g. `?use_test_data_source=1`). + const qIndex = req.originalUrl.indexOf('?'); + const query = qIndex === -1 ? '' : req.originalUrl.slice(qIndex + 1); + + const { contentType, body } = serializeBody(req); + + try { + const upstream = await apiv2Request({ + method: req.method, + path: `/apiv2/${subpath}`, + query, + contentType, + body, + }); + + // If PHP still served the HTML login page (re-login inside apiv2Request + // couldn't recover the session), don't relay it as a misleading 200 — the + // caller would have to sniff HTML to notice. Surface it as a clear 502. + if (isHtmlResponse(upstream.data)) { + logger.error({ subpath, status: upstream.status }, 'apiv2-bridge got HTML (PHP session unavailable)'); + return res.status(502).json({ + error: 'Backend session unavailable', + message: 'The PHP backend returned its login page — the service account session could not be established. Check /api/apiv2-bridge/_health.', + }); + } + + logger.info( + { user: req.user?.username, method: req.method, subpath, status: upstream.status }, + 'apiv2-bridge relayed request' + ); + + res.status(upstream.status).type(upstream.contentType).send(upstream.data); + } catch (err) { + logger.error({ err, subpath }, 'apiv2-bridge relay failed'); + res.status(502).json({ error: 'Upstream backend request failed', message: err.message }); + } +}); + +export default router; diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index 3ceb7b1..109cabe 100644 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -37,6 +37,7 @@ import productEditorAuditLogRouter from './routes/product-editor-audit-log.js'; import newsletterRouter from './routes/newsletter.js'; import linesAggregateRouter from './routes/linesAggregate.js'; import repeatOrdersRouter from './routes/repeat-orders.js'; +import apiv2BridgeRouter from './routes/apiv2-bridge.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -142,6 +143,10 @@ async function startServer() { app.use('/api/newsletter', newsletterRouter); app.use('/api/lines-aggregate', linesAggregateRouter); app.use('/api/repeat-orders', repeatOrdersRouter); + // Side-service: lets external apps (import / product-edit skills) post to the + // legacy PHP /apiv2 API without a browser cookie. Isolated, additive router; + // inherits the same user-JWT auth applied to /api above. See apiv2-bridge.js. + app.use('/api/apiv2-bridge', apiv2BridgeRouter); app.get('/health', (req, res) => { res.json({ diff --git a/inventory-server/src/utils/acotBackendSession.js b/inventory-server/src/utils/acotBackendSession.js new file mode 100644 index 0000000..cb469a8 --- /dev/null +++ b/inventory-server/src/utils/acotBackendSession.js @@ -0,0 +1,285 @@ +// Server-side session manager for the legacy PHP backend (backend.acherryontop.com). +// +// WHY THIS EXISTS +// --------------- +// The PHP `/apiv2/*` write endpoints (product edit, image_changes, taxonomy +// set, setup_new, prod_cat/new, po/new, po/add_products) authenticate via a +// PHP session cookie. In the browser the frontend gets that for free with +// `credentials: 'include'` because the user is logged into +// backend.acherryontop.com in the same browser — that flow is unchanged and +// this module does NOT touch it. +// +// This module backs a separate SIDE-SERVICE (see routes/apiv2-bridge.js) that +// lets EXTERNAL apps (the product-import skill, a future product-edit skill) +// post to the PHP API without a browser cookie. The inventory-server holds the +// PHP session itself: it logs in once with a SERVICE account, caches every +// cookie the backend hands back, and replays them on outbound `/apiv2/*` +// requests. Callers authenticate to the inventory-server with the normal user +// JWT; this PHP-session layer is invisible to them. +// +// LOGIN FLOW (discovered from the live login page) +// ------------------------------------------------ +// 1. GET /login -> sets affinity/session cookies + embeds a +// hidden `anti_csrf` token in the form HTML. +// 2. POST /login/login -> { anti_csrf, userid, pin, after_login:'', +// Submit:'Submit' } with the cookies from (1). +// On success the backend rotates/sets the +// authenticated session cookie. +// +// EXPIRY DETECTION +// ---------------- +// Unauthenticated `/apiv2/*` calls return HTTP 200 with an HTML body (the login +// page), NOT a 401. So we sniff the response body the same way the frontend +// does (isHtmlResponse) and treat an HTML payload as "session dead" -> re-login +// once and retry. + +import axios from 'axios'; +import { logger } from '../../shared/logging/logger.js'; + +const BASE_URL = (process.env.ACOT_BACKEND_URL || 'https://backend.acherryontop.com').replace(/\/$/, ''); +const USERID = process.env.ACOT_BACKEND_USERID; +const PIN = process.env.ACOT_BACKEND_PIN; +const USER_AGENT = 'inventory-server/apiv2-bridge'; + +// Single host => a simple name->value map is a sufficient cookie jar. We do NOT +// hardcode any cookie name: the backend sets a load-balancer affinity cookie +// (`S=...`) AND the real session cookie, and both must be replayed for the +// session to stick. Capturing every Set-Cookie generically handles both. +let cookieJar = new Map(); +// Mutex: collapse concurrent login attempts into one in-flight promise so a +// burst of bridged requests doesn't trigger N parallel logins. +let loginInFlight = null; + +function cookieHeader() { + return Array.from(cookieJar.entries()) + .map(([name, value]) => `${name}=${value}`) + .join('; '); +} + +function storeSetCookies(setCookieHeaders) { + if (!setCookieHeaders) return; + const list = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]; + for (const raw of list) { + const first = String(raw).split(';')[0]; + const eq = first.indexOf('='); + if (eq === -1) continue; + const name = first.slice(0, eq).trim(); + const value = first.slice(eq + 1).trim(); + if (!name) continue; + // A `name=deleted`/empty value means the backend is clearing the cookie. + if (value === '' || value.toLowerCase() === 'deleted') { + cookieJar.delete(name); + } else { + cookieJar.set(name, value); + } + } +} + +export function isHtmlResponse(payload) { + if (typeof payload !== 'string') return false; + const trimmed = payload.trim().toLowerCase(); + return trimmed.startsWith(' whose `type=` matches, tolerant of +// attribute order and quote style. +function inputNameByType(html, type) { + const tags = String(html).match(/]*>/gi) || []; + const typeRe = new RegExp(`type=['"]${type}['"]`, 'i'); + for (const tag of tags) { + if (typeRe.test(tag)) { + const nm = /name=['"]([^'"]+)['"]/i.exec(tag); + if (nm) return nm[1]; + } + } + return null; +} + +function hasPasswordField(html) { + return /]*type=['"]password['"]/i.test(String(html)); +} + +async function performLogin() { + if (!USERID || !PIN) { + throw new Error( + 'ACOT backend service credentials are not configured (set ACOT_BACKEND_USERID and ACOT_BACKEND_PIN)' + ); + } + + // Fresh jar for a clean login. + cookieJar = new Map(); + + // Step 1: GET the login page for cookies + the anti-CSRF token. The login is + // IP-based and serves DIFFERENT forms per network (office: userid/pin; outside: + // username/password), so we parse the served form rather than hardcoding field + // names — the credential pair maps to whatever identifier/secret fields appear. + const getRes = await axios.get(`${BASE_URL}/login`, { + maxRedirects: 0, + validateStatus: () => true, + headers: { 'User-Agent': USER_AGENT }, + responseType: 'text', + transformResponse: (d) => d, + }); + storeSetCookies(getRes.headers['set-cookie']); + const page = getRes.data || ''; + + const csrfMatch = /name=['"]anti_csrf['"][^>]*value=['"]([^'"]+)['"]/i.exec(page); + if (!csrfMatch) { + throw new Error('Could not locate anti_csrf token on the backend login page'); + } + const antiCsrf = csrfMatch[1]; + + const actionMatch = /]*action=['"]([^'"]+)['"]/i.exec(page); + const action = actionMatch ? actionMatch[1] : '/login/login'; + const loginUrl = action.startsWith('http') ? action : `${BASE_URL}${action}`; + + // Identifier field is the first text/email input; secret is the password input. + const idField = inputNameByType(page, 'text') || inputNameByType(page, 'email') || 'username'; + const secretField = inputNameByType(page, 'password') || 'password'; + + // Step 2: POST credentials with the cookies from step 1. + const form = new URLSearchParams(); + form.append('anti_csrf', antiCsrf); + form.append(idField, USERID); + form.append(secretField, PIN); + form.append('after_login', ''); + form.append('Submit', 'Submit'); + + const postRes = await axios.post(loginUrl, form.toString(), { + maxRedirects: 0, + validateStatus: () => true, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'User-Agent': USER_AGENT, + Cookie: cookieHeader(), + }, + responseType: 'text', + transformResponse: (d) => d, + }); + storeSetCookies(postRes.headers['set-cookie']); + + // Success heuristic: a failed login re-renders the login form (a page that + // still has a password field). A redirect, or any non-form response, is + // treated as success here — but the authoritative check is the /apiv2 probe in + // checkSession(), since the form may differ across deployments. + const redirected = postRes.status >= 300 && postRes.status < 400; + if (!redirected && hasPasswordField(postRes.data)) { + throw new Error('Backend login failed — credentials rejected (login form re-served)'); + } + + logger.info( + { status: postRes.status, idField, secretField, cookies: cookieJar.size }, + 'acot backend session established' + ); +} + +async function ensureSession() { + if (cookieJar.size > 0) return; + if (!loginInFlight) { + loginInFlight = performLogin().finally(() => { + loginInFlight = null; + }); + } + await loginInFlight; +} + +async function forceRelogin() { + cookieJar = new Map(); + if (!loginInFlight) { + loginInFlight = performLogin().finally(() => { + loginInFlight = null; + }); + } + await loginInFlight; +} + +/** + * Make an authenticated request to a PHP `/apiv2/...` path, transparently + * handling login and a single re-login-on-expiry retry. + * + * @param {object} opts + * @param {string} opts.method HTTP method (GET/POST/...). + * @param {string} opts.path Path beginning with `/apiv2/...`. + * @param {string} [opts.query] Raw query string (without leading `?`). + * @param {string} [opts.contentType] Outbound Content-Type header. + * @param {string|Buffer} [opts.body] Serialized request body. + * @returns {Promise<{status:number, contentType:string, data:string}>} + */ +export async function apiv2Request({ method, path, query, contentType, body }) { + await ensureSession(); + + const url = `${BASE_URL}${path}${query ? `?${query}` : ''}`; + const send = () => + axios.request({ + url, + method, + data: body, + maxRedirects: 0, + validateStatus: () => true, + responseType: 'text', + transformResponse: (d) => d, + headers: { + 'User-Agent': USER_AGENT, + Cookie: cookieHeader(), + ...(contentType ? { 'Content-Type': contentType } : {}), + }, + }); + + let res = await send(); + + // Session expired / not actually authenticated -> the backend serves the + // HTML login page. Re-login once and retry. + if (isHtmlResponse(res.data)) { + logger.warn({ path }, 'acot apiv2 returned HTML (session expired) — re-logging in'); + await forceRelogin(); + res = await send(); + } + + return { + status: res.status, + contentType: res.headers['content-type'] || 'application/json', + data: typeof res.data === 'string' ? res.data : JSON.stringify(res.data), + }; +} + +/** Force a fresh login, then PROVE the session by probing `/apiv2`. A login + * that merely "doesn't show a login form" is not proof — the only reliable + * signal is that `/apiv2` answers with JSON rather than the HTML login page. + * The probe is a no-op: an empty product edit (`products=[]`) edits nothing. + * Reports cookie NAMES only (never values). Used by the bridge `_health` route. */ +export async function checkSession() { + let loginError = null; + try { + await forceRelogin(); + } catch (err) { + loginError = err.message; + } + + let probe; + try { + probe = await apiv2Request({ + method: 'POST', + path: '/apiv2/product/edit', + contentType: 'application/x-www-form-urlencoded; charset=utf-8', + body: 'products=%5B%5D', // products=[] + }); + } catch (err) { + return { + authenticated: false, + error: `apiv2 probe failed: ${err.message}`, + loginError, + cookieNames: Array.from(cookieJar.keys()), + }; + } + + const servedLoginPage = isHtmlResponse(probe.data); + return { + authenticated: !servedLoginPage, + probeStatus: probe.status, + cookieNames: Array.from(cookieJar.keys()), + ...(loginError ? { loginError } : {}), + sample: servedLoginPage + ? '(received HTML login page — PHP rejected the session)' + : String(probe.data).slice(0, 160), + }; +}