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
GOAL:
- Ensure /api/* requests run under the SAME CF application/session as biz.payfrit.com
by including the parent Application.cfm (because CF uses the nearest Application.cfm).
- Force JSON responses for API requests (never HTML).
- Gate access based on existing biz login + business "Become" selection:
session.UserID / request.UserID
session.BusinessID / request.BusinessID
MVP CHANGE:
- Public allowlist for:
/api/businesses/list.cfm
/api/servicepoints/list.cfm
IMPORTANT:
- 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>
<!---
CRITICAL:
Without this include, /api/* becomes a different app/session and won't see logged-in state.
--->
<cfinclude template="../Application.cfm">
<!--- Force JSON for everything under /api --->
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
@ -32,8 +27,28 @@ function apiAbort(obj) {
abort;
}
// Some apps store auth in session and copy to request in page code.
// Make /api resilient by copying from session if needed.
// Determine current request path
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")) {
request.UserID = Duplicate(session.UserID);
}
@ -41,11 +56,13 @@ if (!structKeyExists(request, "BusinessID") && structKeyExists(session, "Busines
request.BusinessID = Duplicate(session.BusinessID);
}
// Enforce auth for all /api endpoints
if (!structKeyExists(request, "UserID") || !isNumeric(request.UserID) || request.UserID LTE 0) {
// Enforce auth for all /api endpoints EXCEPT allowlisted public endpoints
if (!isPublicEndpoint) {
if (!structKeyExists(request, "UserID") || !isNumeric(request.UserID) || request.UserID LTE 0) {
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" });
}
}
</cfscript>

View file

@ -1,68 +1,91 @@
<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfheader name="Cache-Control" value="no-store">
<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;
}
if (!structKeyExists(request,"UserID") || !isNumeric(request.UserID) || request.UserID LTE 0){
apiAbort({OK=false,ERROR="not_logged_in"});
// NOTE: Do NOT use 'var' at top-level in a .cfm (local scope is function-only).
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
lt.lt_Beacon_Businesses_ServicePointID AS lt_Beacon_Businesses_ServicePointID,
lt.BeaconID AS BeaconID,
b.BeaconName AS BeaconName,
lt.ServicePointID AS ServicePointID,
sp.ServicePointName AS ServicePointName,
lt.lt_Beacon_Businesses_ServicePointNotes AS lt_Beacon_Businesses_ServicePointNotes,
lt.CreatedAt AS CreatedAt
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>
BusinessID AS BusinessID,
BusinessName AS BusinessName
FROM Businesses
ORDER BY BusinessName
",
[],
{ datasource = dsn }
);
<cfscript>
assignments = [];
i = 1;
countVal = q.recordCount;
for (i = 1; i <= q.recordCount; i = i + 1){
// Build keys EXACTLY as the admin UI expects (case-sensitive in JS)
row = {
"lt_Beacon_Businesses_ServicePointID": q["lt_Beacon_Businesses_ServicePointID"][i],
"BeaconID": q["BeaconID"][i],
"BeaconName": q["BeaconName"][i],
"ServicePointID": q["ServicePointID"][i],
"ServicePointName": q["ServicePointName"][i],
"lt_Beacon_Businesses_ServicePointNotes": q["lt_Beacon_Businesses_ServicePointNotes"][i],
"CreatedAt": q["CreatedAt"][i]
};
arrayAppend(assignments, row);
writeOutput('{');
writeOutput('"OK":true');
writeOutput(',"COUNT":' & countVal);
writeOutput(',"Businesses":[');
for (i = 1; i LTE q.recordCount; i = i + 1) {
if (i GT 1) writeOutput(',');
bid = q["BusinessID"][i];
bname = q["BusinessName"][i];
writeOutput('{');
writeOutput('"BusinessID":' & int(bid));
writeOutput(',"BusinessName":"' & encodeForJSON(toString(bname)) & '"');
writeOutput('}');
}
out = {
"OK": true,
"ERROR": "",
"BUSINESSID": (request.BusinessID & ""),
"COUNT": arrayLen(assignments),
"ASSIGNMENTS": assignments
};
writeOutput(']}');
</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">
<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) {
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;
}
function readJsonBody() {
raw = toString(getHttpRequestData().content);
if (isNull(raw) || len(trim(raw)) EQ 0) return {};
try {
parsed = deserializeJSON(raw);
} 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 {};
return parsed;
}
data = readJsonBody();
if (!structKeyExists(request, "UserID") || !isNumeric(request.UserID) || request.UserID LTE 0) {
apiAbort({ OK=false, ERROR="not_logged_in" });
}
if (!structKeyExists(request, "BusinessID") || !isNumeric(request.BusinessID) || request.BusinessID LTE 0) {
apiAbort({ OK=false, ERROR="no_business_selected" });
// Require BusinessID in JSON body for public MVP endpoint
if (!structKeyExists(data, "BusinessID") || !isNumeric(data.BusinessID) || int(data.BusinessID) LTE 0) {
apiAbort({ ERROR="missing_businessid", MESSAGE="Body must include numeric BusinessID" });
}
BusinessID = int(data.BusinessID);
// Default: only active service points unless onlyActive is explicitly false/0
onlyActive = true;
if (structKeyExists(data, "onlyActive")) {
if (isBoolean(data.onlyActive)) {
@ -41,42 +74,64 @@ if (structKeyExists(data, "onlyActive")) {
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>
<cfquery name="q" datasource="#application.datasource#">
<cfquery name="q" datasource="#dsn#">
SELECT
ServicePointID,
BusinessID,
ServicePointBusinessID,
ServicePointName,
ServicePointTypeID,
ServicePointCode,
Description,
SortOrder,
IsActive,
CreatedAt,
UpdatedAt
ServicePointDescription,
ServicePointSortOrder,
ServicePointIsActive,
ServicePointCreatedAt,
ServicePointUpdatedAt
FROM ServicePoints
WHERE BusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#request.BusinessID#">
WHERE ServicePointBusinessID = <cfqueryparam cfsqltype="cf_sql_integer" value="#BusinessID#">
<cfif onlyActive>
AND IsActive = 1
AND ServicePointIsActive = 1
</cfif>
ORDER BY SortOrder, ServicePointName, ServicePointID
ORDER BY ServicePointSortOrder, ServicePointName, ServicePointID
</cfquery>
<cfset sps = []>
<cfloop query="q">
<cfset arrayAppend(sps, {
"ServicePointID"=q.ServicePointID,
"BusinessID"=q.BusinessID,
"ServicePointName"=q.ServicePointName,
"ServicePointTypeID"=q.ServicePointTypeID,
"ServicePointCode"=q.ServicePointCode,
"Description"=q.Description,
"SortOrder"=q.SortOrder,
"IsActive"=q.IsActive,
"CreatedAt"=(q.CreatedAt & ""),
"UpdatedAt"=(q.UpdatedAt & "")
})>
</cfloop>
<cfscript>
// Manual JSON output for exact key casing
writeOutput('{');
writeOutput('"OK":true');
writeOutput(',"ERROR":""');
writeOutput(',"BusinessID":' & BusinessID);
writeOutput(',"COUNT":' & q.recordCount);
writeOutput(',"ServicePoints":[');
<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>