Public businesses + servicepoints list endpoints; API allowlist; Lucee-safe JSON

This commit is contained in:
John Mizerek 2025-12-28 12:30:06 -08:00
parent 10200ad140
commit 848544ba53
4 changed files with 289 additions and 113 deletions

View file

@ -4,25 +4,20 @@
<!--- <!---
FILE: C:\lucee\tomcat\webapps\ROOT\biz.payfrit.com\api\Application.cfm FILE: C:\lucee\tomcat\webapps\ROOT\biz.payfrit.com\api\Application.cfm
GOAL: MVP CHANGE:
- Ensure /api/* requests run under the SAME CF application/session as biz.payfrit.com - Public allowlist for:
by including the parent Application.cfm (because CF uses the nearest Application.cfm). /api/businesses/list.cfm
- Force JSON responses for API requests (never HTML). /api/servicepoints/list.cfm
- Gate access based on existing biz login + business "Become" selection:
session.UserID / request.UserID IMPORTANT:
session.BusinessID / request.BusinessID - Do NOT rely on exact SCRIPT_NAME equality; in some deployments it may include
the site folder prefix (e.g. /biz.payfrit.com/api/...).
- So we allowlist by "contains" match.
---> --->
<!--- Mark API context BEFORE including parent, in case parent logic checks it --->
<cfset request.IsApiRequest = true> <cfset request.IsApiRequest = true>
<!---
CRITICAL:
Without this include, /api/* becomes a different app/session and won't see logged-in state.
--->
<cfinclude template="../Application.cfm"> <cfinclude template="../Application.cfm">
<!--- Force JSON for everything under /api --->
<cfcontent type="application/json; charset=utf-8" reset="true"> <cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store"> <cfheader name="Cache-Control" value="no-store">
@ -32,8 +27,28 @@ function apiAbort(obj) {
abort; abort;
} }
// Some apps store auth in session and copy to request in page code. // Determine current request path
// Make /api resilient by copying from session if needed. scriptName = "";
if (structKeyExists(cgi, "SCRIPT_NAME")) {
scriptName = cgi.SCRIPT_NAME;
} else if (structKeyExists(cgi, "PATH_INFO")) {
scriptName = cgi.PATH_INFO;
}
// MVP allowlist: PUBLIC endpoint(s) under /api
isPublicEndpoint = false;
if (len(scriptName)) {
// Use contains (case-insensitive) so it works whether SCRIPT_NAME is:
// /api/servicepoints/list.cfm OR /biz.payfrit.com/api/servicepoints/list.cfm
if (findNoCase("/api/businesses/list.cfm", scriptName) GT 0) {
isPublicEndpoint = true;
}
if (findNoCase("/api/servicepoints/list.cfm", scriptName) GT 0) {
isPublicEndpoint = true;
}
}
// Copy auth from session if present
if (!structKeyExists(request, "UserID") && structKeyExists(session, "UserID")) { if (!structKeyExists(request, "UserID") && structKeyExists(session, "UserID")) {
request.UserID = Duplicate(session.UserID); request.UserID = Duplicate(session.UserID);
} }
@ -41,11 +56,13 @@ if (!structKeyExists(request, "BusinessID") && structKeyExists(session, "Busines
request.BusinessID = Duplicate(session.BusinessID); request.BusinessID = Duplicate(session.BusinessID);
} }
// Enforce auth for all /api endpoints // Enforce auth for all /api endpoints EXCEPT allowlisted public endpoints
if (!structKeyExists(request, "UserID") || !isNumeric(request.UserID) || request.UserID LTE 0) { if (!isPublicEndpoint) {
if (!structKeyExists(request, "UserID") || !isNumeric(request.UserID) || request.UserID LTE 0) {
apiAbort({ OK=false, ERROR="not_logged_in" }); apiAbort({ OK=false, ERROR="not_logged_in" });
} }
if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) { if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) {
apiAbort({ OK=false, ERROR="no_business_selected" }); apiAbort({ OK=false, ERROR="no_business_selected" });
}
} }
</cfscript> </cfscript>

View file

@ -1,68 +1,91 @@
<cfsetting showdebugoutput="false"> <cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true"> <cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<cfscript> <cfscript>
function apiAbort(obj){ /*
writeOutput(serializeJSON(obj)); FILE: C:\lucee\tomcat\webapps\ROOT\biz.payfrit.com\api\businesses\list.cfm
MVP:
- PUBLIC endpoint (no login required)
- Returns a minimal list of restaurants/businesses for the drilldown.
- IMPORTANT: Output JSON manually to preserve exact key casing.
RESPONSE (case-sensitive keys):
{
"OK": true,
"COUNT": 15,
"Businesses": [
{ "BusinessID": 12, "BusinessName": "Annie's Soul Delicious" },
...
]
}
*/
function apiAbort(obj) {
writeOutput('{');
writeOutput('"OK":' & (obj.OK ? 'true' : 'false'));
if (structKeyExists(obj, "ERROR")) {
writeOutput(',"ERROR":"' & encodeForJSON(toString(obj.ERROR)) & '"');
}
if (structKeyExists(obj, "DETAIL")) {
writeOutput(',"DETAIL":"' & encodeForJSON(toString(obj.DETAIL)) & '"');
}
writeOutput('}');
abort; abort;
} }
if (!structKeyExists(request,"UserID") || !isNumeric(request.UserID) || request.UserID LTE 0){ // NOTE: Do NOT use 'var' at top-level in a .cfm (local scope is function-only).
apiAbort({OK=false,ERROR="not_logged_in"}); dsn = "";
if (structKeyExists(request, "datasource") && len(trim(request.datasource))) {
dsn = trim(request.datasource);
} else if (structKeyExists(request, "dsn") && len(trim(request.dsn))) {
dsn = trim(request.dsn);
} else if (structKeyExists(application, "datasource") && len(trim(application.datasource))) {
dsn = trim(application.datasource);
} else if (structKeyExists(application, "dsn") && len(trim(application.dsn))) {
dsn = trim(application.dsn);
} }
if (!structKeyExists(request,"BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0){
apiAbort({OK=false,ERROR="no_business_selected"});
}
</cfscript>
<cfquery name="q" datasource="#application.datasource#"> if (!len(dsn)) {
apiAbort({
OK=false,
ERROR="missing_datasource",
DETAIL="No datasource found in request.datasource, request.dsn, application.datasource, or application.dsn."
});
}
q = queryExecute(
"
SELECT SELECT
lt.lt_Beacon_Businesses_ServicePointID AS lt_Beacon_Businesses_ServicePointID, BusinessID AS BusinessID,
lt.BeaconID AS BeaconID, BusinessName AS BusinessName
b.BeaconName AS BeaconName, FROM Businesses
lt.ServicePointID AS ServicePointID, ORDER BY BusinessName
sp.ServicePointName AS ServicePointName, ",
lt.lt_Beacon_Businesses_ServicePointNotes AS lt_Beacon_Businesses_ServicePointNotes, [],
lt.CreatedAt AS CreatedAt { datasource = dsn }
FROM lt_Beacon_Businesses_ServicePoints lt );
INNER JOIN Beacons b
ON b.BeaconID = lt.BeaconID
AND b.BusinessID = lt.BusinessID
INNER JOIN ServicePoints sp
ON sp.ServicePointID = lt.ServicePointID
AND sp.BusinessID = lt.BusinessID
WHERE lt.BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
ORDER BY lt.lt_Beacon_Businesses_ServicePointID DESC
</cfquery>
<cfscript> countVal = q.recordCount;
assignments = [];
i = 1;
for (i = 1; i <= q.recordCount; i = i + 1){ writeOutput('{');
// Build keys EXACTLY as the admin UI expects (case-sensitive in JS) writeOutput('"OK":true');
row = { writeOutput(',"COUNT":' & countVal);
"lt_Beacon_Businesses_ServicePointID": q["lt_Beacon_Businesses_ServicePointID"][i], writeOutput(',"Businesses":[');
"BeaconID": q["BeaconID"][i],
"BeaconName": q["BeaconName"][i], for (i = 1; i LTE q.recordCount; i = i + 1) {
"ServicePointID": q["ServicePointID"][i], if (i GT 1) writeOutput(',');
"ServicePointName": q["ServicePointName"][i],
"lt_Beacon_Businesses_ServicePointNotes": q["lt_Beacon_Businesses_ServicePointNotes"][i], bid = q["BusinessID"][i];
"CreatedAt": q["CreatedAt"][i] bname = q["BusinessName"][i];
};
arrayAppend(assignments, row); writeOutput('{');
writeOutput('"BusinessID":' & int(bid));
writeOutput(',"BusinessName":"' & encodeForJSON(toString(bname)) & '"');
writeOutput('}');
} }
out = { writeOutput(']}');
"OK": true,
"ERROR": "",
"BUSINESSID": (request.BusinessID & ""),
"COUNT": arrayLen(assignments),
"ASSIGNMENTS": assignments
};
</cfscript> </cfscript>
<cfoutput>#serializeJSON(out)#</cfoutput>

81
api/businesses/list.cfm Normal file
View file

@ -0,0 +1,81 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfscript>
/*
PUBLIC MVP endpoint
Returns Businesses list with STRICT casing:
- Businesses
- BusinessID
- BusinessName
Lucee-safe JSON output (NO encodeForJSON)
*/
function jsonString(val) {
// serializeJSON("abc") -> "\"abc\""
// we strip the surrounding quotes
var s = serializeJSON(toString(val));
return mid(s, 2, len(s) - 2);
}
function apiAbort(obj) {
writeOutput('{');
writeOutput('"OK":false');
if (structKeyExists(obj, "ERROR")) {
writeOutput(',"ERROR":"' & jsonString(obj.ERROR) & '"');
}
if (structKeyExists(obj, "DETAIL")) {
writeOutput(',"DETAIL":"' & jsonString(obj.DETAIL) & '"');
}
writeOutput('}');
abort;
}
/* ---- datasource resolution (unchanged) ---- */
dsn = "";
if (structKeyExists(application, "datasource")) {
dsn = application.datasource;
} else if (structKeyExists(application, "dsn")) {
dsn = application.dsn;
}
if (!len(dsn)) {
apiAbort({
ERROR = "missing_datasource",
DETAIL = "No datasource configured"
});
}
/* ---- query ---- */
q = queryExecute(
"
SELECT
BusinessID,
BusinessName
FROM Businesses
ORDER BY BusinessName
",
[],
{ datasource = dsn }
);
/* ---- output ---- */
writeOutput('{');
writeOutput('"OK":true');
writeOutput(',"COUNT":' & q.recordCount);
writeOutput(',"Businesses":[');
for (i = 1; i LTE q.recordCount; i = i + 1) {
if (i GT 1) writeOutput(',');
writeOutput('{');
writeOutput('"BusinessID":' & q.BusinessID[i]);
writeOutput(',"BusinessName":"' & jsonString(q.BusinessName[i]) & '"');
writeOutput('}');
}
writeOutput(']}');
</cfscript>

View file

@ -5,32 +5,65 @@
<cfheader name="Cache-Control" value="no-store"> <cfheader name="Cache-Control" value="no-store">
<cfscript> <cfscript>
/*
FILE: C:\lucee\tomcat\webapps\ROOT\biz.payfrit.com\api\servicepoints\list.cfm
MVP:
- PUBLIC (no login required)
- Reads BusinessID from JSON body
- Optional: onlyActive (default true)
- Returns hump-case JSON:
OK, ERROR, BusinessID, COUNT, ServicePoints
*/
function jsonString(val) {
// Lucee-safe string escaping:
// serializeJSON("abc") -> "\"abc\"" ; strip surrounding quotes
s = serializeJSON(toString(val));
return mid(s, 2, len(s) - 2);
}
function apiAbort(obj) { function apiAbort(obj) {
writeOutput(serializeJSON(obj)); writeOutput('{');
writeOutput('"OK":false');
if (structKeyExists(obj, "ERROR")) {
writeOutput(',"ERROR":"' & jsonString(obj.ERROR) & '"');
}
if (structKeyExists(obj, "MESSAGE")) {
writeOutput(',"MESSAGE":"' & jsonString(obj.MESSAGE) & '"');
}
if (structKeyExists(obj, "DETAIL")) {
writeOutput(',"DETAIL":"' & jsonString(obj.DETAIL) & '"');
}
writeOutput('}');
abort; abort;
} }
function readJsonBody() { function readJsonBody() {
raw = toString(getHttpRequestData().content); raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {}; if (isNull(raw) || len(trim(raw)) EQ 0) return {};
try { try {
parsed = deserializeJSON(raw); parsed = deserializeJSON(raw);
} catch(any e) { } catch(any e) {
apiAbort({ OK=false, ERROR="bad_json", MESSAGE="Invalid JSON body" }); apiAbort({ ERROR="bad_json", MESSAGE="Invalid JSON body" });
} }
if (!isStruct(parsed)) return {}; if (!isStruct(parsed)) return {};
return parsed; return parsed;
} }
data = readJsonBody(); data = readJsonBody();
if (!structKeyExists(request, "UserID") || !isNumeric(request.UserID) || request.UserID LTE 0) { // Require BusinessID in JSON body for public MVP endpoint
apiAbort({ OK=false, ERROR="not_logged_in" }); if (!structKeyExists(data, "BusinessID") || !isNumeric(data.BusinessID) || int(data.BusinessID) LTE 0) {
} apiAbort({ ERROR="missing_businessid", MESSAGE="Body must include numeric BusinessID" });
if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) {
apiAbort({ OK=false, ERROR="no_business_selected" });
} }
BusinessID = int(data.BusinessID);
// Default: only active service points unless onlyActive is explicitly false/0
onlyActive = true; onlyActive = true;
if (structKeyExists(data, "onlyActive")) { if (structKeyExists(data, "onlyActive")) {
if (isBoolean(data.onlyActive)) { if (isBoolean(data.onlyActive)) {
@ -41,42 +74,64 @@ if (structKeyExists(data, "onlyActive")) {
onlyActive = (lcase(trim(toString(data.onlyActive))) EQ "true"); onlyActive = (lcase(trim(toString(data.onlyActive))) EQ "true");
} }
} }
// Datasource (use existing app config)
dsn = "";
if (structKeyExists(application, "datasource") && len(trim(toString(application.datasource)))) {
dsn = trim(toString(application.datasource));
} else if (structKeyExists(application, "dsn") && len(trim(toString(application.dsn)))) {
dsn = trim(toString(application.dsn));
}
if (!len(dsn)) {
apiAbort({ ERROR="missing_datasource", MESSAGE="application.datasource is not set" });
}
</cfscript> </cfscript>
<cfquery name="q" datasource="#application.datasource#"> <cfquery name="q" datasource="#dsn#">
SELECT SELECT
ServicePointID, ServicePointID,
BusinessID, ServicePointBusinessID,
ServicePointName, ServicePointName,
ServicePointTypeID, ServicePointTypeID,
ServicePointCode, ServicePointCode,
Description, ServicePointDescription,
SortOrder, ServicePointSortOrder,
IsActive, ServicePointIsActive,
CreatedAt, ServicePointCreatedAt,
UpdatedAt ServicePointUpdatedAt
FROM ServicePoints FROM ServicePoints
WHERE BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#"> WHERE ServicePointBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#BusinessID#">
<cfif onlyActive> <cfif onlyActive>
AND IsActive = 1 AND ServicePointIsActive = 1
</cfif> </cfif>
ORDER BY SortOrder, ServicePointName, ServicePointID ORDER BY ServicePointSortOrder, ServicePointName, ServicePointID
</cfquery> </cfquery>
<cfset sps = []> <cfscript>
<cfloop query="q"> // Manual JSON output for exact key casing
<cfset arrayAppend(sps, { writeOutput('{');
"ServicePointID"=q.ServicePointID, writeOutput('"OK":true');
"BusinessID"=q.BusinessID, writeOutput(',"ERROR":""');
"ServicePointName"=q.ServicePointName, writeOutput(',"BusinessID":' & BusinessID);
"ServicePointTypeID"=q.ServicePointTypeID, writeOutput(',"COUNT":' & q.recordCount);
"ServicePointCode"=q.ServicePointCode, writeOutput(',"ServicePoints":[');
"Description"=q.Description,
"SortOrder"=q.SortOrder,
"IsActive"=q.IsActive,
"CreatedAt"=(q.CreatedAt & ""),
"UpdatedAt"=(q.UpdatedAt & "")
})>
</cfloop>
<cfoutput>#serializeJSON({ OK=true, ERROR="", BUSINESSID=(request.BusinessID & ""), COUNT=arrayLen(sps), SERVICEPOINTS=sps })#</cfoutput> for (i = 1; i LTE q.recordCount; i = i + 1) {
if (i GT 1) writeOutput(',');
writeOutput('{');
writeOutput('"ServicePointID":' & q.ServicePointID[i]);
writeOutput(',"BusinessID":' & q.ServicePointBusinessID[i]);
writeOutput(',"ServicePointName":"' & jsonString(q.ServicePointName[i]) & '"');
writeOutput(',"ServicePointTypeID":' & q.ServicePointTypeID[i]);
writeOutput(',"ServicePointCode":' & (isNull(q.ServicePointCode[i]) ? 'null' : '"' & jsonString(q.ServicePointCode[i]) & '"'));
writeOutput(',"ServicePointDescription":' & (isNull(q.ServicePointDescription[i]) ? 'null' : '"' & jsonString(q.ServicePointDescription[i]) & '"'));
writeOutput(',"ServicePointSortOrder":' & q.ServicePointSortOrder[i]);
writeOutput(',"ServicePointIsActive":' & q.ServicePointIsActive[i]);
writeOutput(',"ServicePointCreatedAt":"' & jsonString(q.ServicePointCreatedAt[i] & "") & '"');
writeOutput(',"ServicePointUpdatedAt":"' & jsonString(q.ServicePointUpdatedAt[i] & "") & '"');
writeOutput('}');
}
writeOutput(']}');
</cfscript>