From 848544ba5300f29221506fbfc0d909f7a2da6a2a Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Sun, 28 Dec 2025 12:30:06 -0800 Subject: [PATCH] Public businesses + servicepoints list endpoints; API allowlist; Lucee-safe JSON --- api/Application.cfm | 61 ++++++++++------ api/assignments/list.cfm | 139 +++++++++++++++++++++---------------- api/businesses/list.cfm | 81 +++++++++++++++++++++ api/servicepoints/list.cfm | 121 +++++++++++++++++++++++--------- 4 files changed, 289 insertions(+), 113 deletions(-) create mode 100644 api/businesses/list.cfm diff --git a/api/Application.cfm b/api/Application.cfm index 430398c..b71632e 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -4,25 +4,20 @@ - - - - @@ -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) { - 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" }); +// 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) { + apiAbort({ OK=false, ERROR="no_business_selected" }); + } } diff --git a/api/assignments/list.cfm b/api/assignments/list.cfm index d88282f..21b22a0 100644 --- a/api/assignments/list.cfm +++ b/api/assignments/list.cfm @@ -1,68 +1,91 @@ - - - -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"}); + +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 + BusinessID AS BusinessID, + BusinessName AS BusinessName + FROM Businesses + ORDER BY BusinessName + ", + [], + { datasource = dsn } +); + +countVal = q.recordCount; + +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('}'); +} + +writeOutput(']}'); - - - 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 = - ORDER BY lt.lt_Beacon_Businesses_ServicePointID DESC - - - -assignments = []; -i = 1; - -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); -} - -out = { - "OK": true, - "ERROR": "", - "BUSINESSID": (request.BusinessID & ""), - "COUNT": arrayLen(assignments), - "ASSIGNMENTS": assignments -}; - - -#serializeJSON(out)# diff --git a/api/businesses/list.cfm b/api/businesses/list.cfm new file mode 100644 index 0000000..b9a4d2f --- /dev/null +++ b/api/businesses/list.cfm @@ -0,0 +1,81 @@ + + + + +/* + 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(']}'); + diff --git a/api/servicepoints/list.cfm b/api/servicepoints/list.cfm index 8854425..a198886 100644 --- a/api/servicepoints/list.cfm +++ b/api/servicepoints/list.cfm @@ -5,32 +5,65 @@ +/* + 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" }); +} - + SELECT ServicePointID, - BusinessID, + ServicePointBusinessID, ServicePointName, ServicePointTypeID, ServicePointCode, - Description, - SortOrder, - IsActive, - CreatedAt, - UpdatedAt + ServicePointDescription, + ServicePointSortOrder, + ServicePointIsActive, + ServicePointCreatedAt, + ServicePointUpdatedAt FROM ServicePoints - WHERE BusinessID = + WHERE ServicePointBusinessID = - AND IsActive = 1 + AND ServicePointIsActive = 1 - ORDER BY SortOrder, ServicePointName, ServicePointID + ORDER BY ServicePointSortOrder, ServicePointName, ServicePointID - - - - + +// Manual JSON output for exact key casing +writeOutput('{'); +writeOutput('"OK":true'); +writeOutput(',"ERROR":""'); +writeOutput(',"BusinessID":' & BusinessID); +writeOutput(',"COUNT":' & q.recordCount); +writeOutput(',"ServicePoints":['); -#serializeJSON({ OK=true, ERROR="", BUSINESSID=(request.BusinessID & ""), COUNT=arrayLen(sps), SERVICEPOINTS=sps })# +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(']}'); +