Public businesses + servicepoints list endpoints; API allowlist; Lucee-safe JSON
This commit is contained in:
parent
10200ad140
commit
848544ba53
4 changed files with 289 additions and 113 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
81
api/businesses/list.cfm
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue