Add apiv2 bridge router to server for legacy API integration
This commit is contained in:
@@ -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('<!doctype html') || trimmed.startsWith('<html');
|
||||
}
|
||||
|
||||
// Find the `name=` of the first <input> whose `type=` matches, tolerant of
|
||||
// attribute order and quote style.
|
||||
function inputNameByType(html, type) {
|
||||
const tags = String(html).match(/<input\b[^>]*>/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 /<input\b[^>]*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 = /<form\b[^>]*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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user