From 4feb5d0fc44c941054b7574cf08589a553ac0767 Mon Sep 17 00:00:00 2001 From: btopro Date: Thu, 6 Aug 2026 13:36:20 -0400 Subject: [PATCH] Normalize v1 API conformance: canonical spec + Node fixes Spec (canonical): - LoginRequest adds jwt (revalidate); auth failures 401 per spec - POST settings aliases documented as read-only (no requestBody) - /session security [] (anon login probe) - provider-search requires bearerAuth + siteTokenHeader - GET /x/api/v1/files documents filename param - single-user assumption notes on config-write ops Node code: - views/blocks: anonymous-visibility enforcement on results/usage - files dateCreated in seconds (was milliseconds) - content concat HTML escapes record.id/title - views tags source honors ?sort (default -count) - exports: supportedFormats inside data; html export as file download - archiveSite uniquifies collided archive names - connectionSettings adds no-store cache headers - login failures 401 + JSON envelopes; keeps JWT-revalidate - removes POST skeletons/:name read alias - provider-search site token enforced in router - downloadSite excludes node_modules; drops dead getUserData code Refs: haxtheweb/issues conformance audit (site/system parity) Co-Authored-By: Oz --- src/app.js | 63 +++++- src/lib/SystemRoutesMap.js | 7 - src/openapi/site-spec.yaml | 11 ++ src/openapi/system-spec.yaml | 181 ++++++++++++------ src/siteRoutes/v1/blocks.js | 18 +- src/siteRoutes/v1/content.js | 9 +- src/siteRoutes/v1/exports.js | 17 +- src/siteRoutes/v1/files.js | 3 +- src/siteRoutes/v1/views.js | 25 ++- src/systemRoutes/v1/routes/archiveSite.js | 22 ++- .../v1/routes/connectionSettings.js | 5 + src/systemRoutes/v1/routes/downloadSite.js | 10 +- src/systemRoutes/v1/routes/getUserData.js | 14 -- src/systemRoutes/v1/routes/login.js | 27 ++- 14 files changed, 308 insertions(+), 104 deletions(-) diff --git a/src/app.js b/src/app.js index 17b15c44..93019c29 100644 --- a/src/app.js +++ b/src/app.js @@ -1238,7 +1238,7 @@ systemStructureContext().then((site) => { for (let systemRoute in systemRouteRegistry[systemMethod]) { const systemRoutePath = `${systemApiV1BasePath}${systemRoute}`; const systemRouteParser = getSystemV1RouteParser(systemMethod, systemRoute); - const systemRouteHandler = (req, res, next) => { + const systemRouteHandler = async (req, res, next) => { const op = req.route.path.replace(systemApiV1BasePath, ''); const rMethod = req.method.toLowerCase(); if (!validateSystemV1RouteAccess(req, op)) { @@ -1263,6 +1263,12 @@ systemStructureContext().then((site) => { if (!enforceSystemApiUserTokenPolicy(req, res, op, rMethod, basicAuth)) { return; } + // F2/Q14+F3: enforce site-token policy for 'authenticated-site' + // system routes (e.g. provider-search) so the router returns a + // consistent 403 envelope instead of the handler doing the check. + if (!await enforceSystemApiSiteTokenPolicy(req, res, op, rMethod, basicAuth)) { + return; + } return systemRouteRegistry[rMethod][op](req, res, next); } // D1b status-code parity (matches site API + PHP SystemApiSecurity): @@ -1280,7 +1286,7 @@ systemStructureContext().then((site) => { data: { message: 'Authentication required' }, }); }; - const siteScopedSystemRouteHandler = (req, res, next) => { + const siteScopedSystemRouteHandler = async (req, res, next) => { const op = req.route.path.replace( `/${HAXCMS.sitesDirectory}/*${systemApiV1BasePath}`, '', @@ -1308,6 +1314,12 @@ systemStructureContext().then((site) => { if (!enforceSystemApiUserTokenPolicy(req, res, op, rMethod, basicAuth)) { return; } + // F2/Q14+F3: enforce site-token policy for 'authenticated-site' + // system routes (e.g. provider-search) so the router returns a + // consistent 403 envelope instead of the handler doing the check. + if (!await enforceSystemApiSiteTokenPolicy(req, res, op, rMethod, basicAuth)) { + return; + } return systemRouteRegistry[rMethod][op](req, res, next); } // D1b status-code parity (matches site API + PHP SystemApiSecurity): @@ -1826,6 +1838,53 @@ function enforceSystemApiUserTokenPolicy(req, res, op, method, basicAuth) { } return true; } +// F2/Q14+F3: site-token enforcement for 'authenticated-site' system routes +// (e.g. provider-search). Mirrors the site API's 'authenticated-site' policy +// but runs in the system route handler so the 403 envelope is consistent +// with the rest of the system API. The handler may still do semantic +// siteName validation (e.g. generateAppStore) — this function only gates on +// token presence + validity against the resolved siteName + userName. +async function enforceSystemApiSiteTokenPolicy(req, res, op, method, basicAuth) { + const policy = getSystemApiRouteAuthPolicy(op, method); + if (policy !== 'authenticated-site') { + return true; + } + const siteToken = getRequestHeaderValue(req, 'x-haxcms-site-token'); + if (siteToken === '') { + res.status(403).json({ + status: 403, + data: { message: 'X-HAXCMS-Site-Token header is required for this endpoint' }, + }); + return false; + } + const userName = resolveSystemApiAuthenticatedUserName(req, basicAuth); + if (userName === '') { + res.status(403).json({ + status: 403, + data: { message: 'Unable to resolve authenticated user context' }, + }); + return false; + } + const siteName = await resolveSiteApiRequestSiteName(req, { + userName: userName, + siteToken: siteToken, + }); + if (!siteName) { + res.status(403).json({ + status: 403, + data: { message: 'Unable to resolve site token context' }, + }); + return false; + } + if (!HAXCMS.validateRequestToken(siteToken, `${userName}:${siteName}`)) { + res.status(403).json({ + status: 403, + data: { message: 'Invalid X-HAXCMS-Site-Token header' }, + }); + return false; + } + return true; +} function assertSiteApiMutationRoutesAreSecured(routeRegistry = null) { const registry = routeRegistry && typeof routeRegistry === 'object' ? routeRegistry : {}; diff --git a/src/lib/SystemRoutesMap.js b/src/lib/SystemRoutesMap.js index 77f6dda0..7ac94bd2 100644 --- a/src/lib/SystemRoutesMap.js +++ b/src/lib/SystemRoutesMap.js @@ -245,12 +245,6 @@ addRouteHandler( 'skeletons/:skeletonName', settingsRoutes.getSkeleton, ); -addRouteHandler( - SystemRoutesMap, - 'post', - 'skeletons/:skeletonName', - settingsRoutes.getSkeleton, -); addRouteHandler( SystemRoutesMap, 'patch', @@ -301,7 +295,6 @@ const SystemV1OpenRoutes = [ 'session/connection-settings', 'session/connection-test', 'integrations/app-store', - 'integrations/app-store/providers/:provider/search', '', 'openapi', 'openapi.json', diff --git a/src/openapi/site-spec.yaml b/src/openapi/site-spec.yaml index 0209cb23..65cc0102 100644 --- a/src/openapi/site-spec.yaml +++ b/src/openapi/site-spec.yaml @@ -1214,6 +1214,7 @@ paths: - $ref: "#/components/parameters/FilterExtension" - $ref: "#/components/parameters/FilterStartsWith" - $ref: "#/components/parameters/FilterNameContains" + - $ref: "#/components/parameters/FileName" - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" - $ref: "#/components/parameters/Sort" @@ -2188,6 +2189,16 @@ components: required: false schema: type: string + FileName: + name: filename + in: query + required: false + schema: + type: string + description: > + Substring filter applied to both the file relative path and the file + name. When supplied, only files whose path or name contains the value + (case-insensitive) are returned. FilterKind: name: filter.kind in: query diff --git a/src/openapi/system-spec.yaml b/src/openapi/system-spec.yaml index 0a3fa3c4..cfe4ef21 100644 --- a/src/openapi/system-spec.yaml +++ b/src/openapi/system-spec.yaml @@ -117,8 +117,11 @@ paths: - session operationId: sessionAuthGet summary: Validate a JWT and return authenticated session details - security: - - bearerAuth: [] + description: > + Anonymous login probe. Returns session details when a valid JWT is + supplied and a minimal unauthenticated response otherwise. No bearer + auth is required to reach this endpoint. + security: [] responses: "200": description: Authenticated session details @@ -135,8 +138,11 @@ paths: - session operationId: sessionAuthPost summary: Validate a JWT and return authenticated session details - security: - - bearerAuth: [] + description: > + Anonymous login probe. Returns session details when a valid JWT is + supplied (body or query) and a minimal unauthenticated response + otherwise. No bearer auth is required to reach this endpoint. + security: [] requestBody: required: false content: @@ -564,10 +570,20 @@ paths: description: > Proxies a search request to a registered app-store provider (e.g. product-card, media, etc.) and returns the provider's response payload. - Moved to the system API in D38; documented here per D57. + Moved to the system API in D38; documented here per D57. Requires a + valid bearer JWT and site token so only authenticated site contexts + can broker upstream provider searches. parameters: - $ref: "#/components/parameters/AppStoreProvider" - security: [] + - name: siteName + in: query + required: true + schema: + type: string + description: Site machine name used to validate site token scope + security: + - bearerAuth: [] + siteTokenHeader: [] responses: "200": description: Provider search response payload @@ -788,19 +804,20 @@ paths: post: tags: - settings - operationId: saveApiKeysPost - summary: Update API key settings + operationId: getApiKeysPost + summary: Return configured API keys and provider statuses (read alias of GET) + description: > + Read-only alias of GET /configuration/api-keys. POST is accepted for + callers that prefer POST over GET but does not write; use PATCH to + update API key settings. Single-user deployment assumption: the + NodeJS backend does not model an admin/superUser tier; these settings + are written by the single authenticated dashboard user. security: - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ApiKeysSettings" + userTokenHeader: [] responses: "200": - description: API key update response + description: API key settings content: application/json: schema: @@ -812,6 +829,11 @@ paths: - settings operationId: saveApiKeysPatch summary: Update API key settings + description: > + Write operation — updates API key settings. Single-user deployment + assumption: the NodeJS backend does not model an admin/superUser + tier; settings are written by the single authenticated dashboard + user. security: - bearerAuth: [] userTokenHeader: [] @@ -851,19 +873,20 @@ paths: post: tags: - settings - operationId: saveMediaSettingsPost - summary: Update media and upload configuration + operationId: getMediaSettingsPost + summary: Return media and upload configuration (read alias of GET) + description: > + Read-only alias of GET /configuration/media. POST is accepted for + callers that prefer POST over GET but does not write; use PATCH to + update media settings. Single-user deployment assumption: the + NodeJS backend does not model an admin/superUser tier; these settings + are written by the single authenticated dashboard user. security: - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/MediaSettings" + userTokenHeader: [] responses: "200": - description: Media settings update response + description: Media settings content: application/json: schema: @@ -875,6 +898,11 @@ paths: - settings operationId: saveMediaSettingsPatch summary: Update media and upload configuration + description: > + Write operation — updates media and upload configuration. Single-user + deployment assumption: the NodeJS backend does not model an + admin/superUser tier; settings are written by the single authenticated + dashboard user. security: - bearerAuth: [] userTokenHeader: [] @@ -899,6 +927,12 @@ paths: - settings operationId: schemaFileOperation summary: Perform schema file operation in system configuration storage + description: > + Write operation — performs schema file operations (rename, delete, + upload) in system configuration storage. Single-user deployment + assumption: the NodeJS backend does not model an admin/superUser + tier; operations are performed by the single authenticated dashboard + user. security: - bearerAuth: [] userTokenHeader: [] @@ -925,6 +959,7 @@ paths: summary: Return available system blocks security: - bearerAuth: [] + userTokenHeader: [] responses: "200": description: Available block list @@ -937,19 +972,20 @@ paths: post: tags: - settings - operationId: saveEnabledBlocksPost - summary: Update enabled block configuration using the blocks collection endpoint + operationId: systemBlocksPost + summary: Return available system blocks (read alias of GET) + description: > + Read-only alias of GET /blocks. POST is accepted for callers that + prefer POST over GET but does not write; use PATCH to update enabled + block configuration. Single-user deployment assumption: the NodeJS + backend does not model an admin/superUser tier; these settings are + written by the single authenticated dashboard user. security: - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EnabledCollectionSettings" + userTokenHeader: [] responses: "200": - description: Enabled collection update response + description: Available block list content: application/json: schema: @@ -961,6 +997,11 @@ paths: - settings operationId: saveEnabledBlocksPatch summary: Update enabled block configuration using the blocks collection endpoint + description: > + Write operation — updates enabled block configuration. Single-user + deployment assumption: the NodeJS backend does not model an + admin/superUser tier; settings are written by the single authenticated + dashboard user. security: - bearerAuth: [] userTokenHeader: [] @@ -987,6 +1028,7 @@ paths: summary: Return available system skeletons security: - bearerAuth: [] + userTokenHeader: [] responses: "200": description: Available skeleton list @@ -1000,27 +1042,23 @@ paths: tags: - settings operationId: systemSkeletonsPost - summary: Upload a skeleton resource or perform skeleton collection operations + summary: Return available system skeletons (read alias of GET) + description: > + Read-only alias of GET /skeletons. POST is accepted for callers that + prefer POST over GET but does not write; use PATCH to update enabled + skeleton configuration. Single-user deployment assumption: the NodeJS + backend does not model an admin/superUser tier; these settings are + written by the single authenticated dashboard user. security: - bearerAuth: [] - requestBody: - required: false - content: - multipart/form-data: - schema: - $ref: "#/components/schemas/SkeletonUploadRequest" - application/json: - schema: - $ref: "#/components/schemas/SkeletonMutationRequest" + userTokenHeader: [] responses: "200": - description: Skeleton resource operation response + description: Available skeleton list content: application/json: schema: $ref: "#/components/schemas/ApiEnvelope" - "400": - $ref: "#/components/responses/BadRequest" "403": $ref: "#/components/responses/Forbidden" patch: @@ -1029,6 +1067,11 @@ paths: operationId: saveEnabledSkeletonsPatch summary: Update enabled skeleton configuration using the skeletons collection endpoint + description: > + Write operation — updates enabled skeleton configuration. Single-user + deployment assumption: the NodeJS backend does not model an + admin/superUser tier; settings are written by the single authenticated + dashboard user. security: - bearerAuth: [] userTokenHeader: [] @@ -1161,6 +1204,7 @@ paths: summary: Return available system themes security: - bearerAuth: [] + userTokenHeader: [] responses: "200": description: Available theme list @@ -1173,19 +1217,20 @@ paths: post: tags: - settings - operationId: saveEnabledThemesPost - summary: Update enabled theme configuration using the themes collection endpoint + operationId: systemThemesPost + summary: Return available system themes (read alias of GET) + description: > + Read-only alias of GET /themes. POST is accepted for callers that + prefer POST over GET but does not write; use PATCH to update enabled + theme configuration. Single-user deployment assumption: the NodeJS + backend does not model an admin/superUser tier; these settings are + written by the single authenticated dashboard user. security: - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EnabledCollectionSettings" + userTokenHeader: [] responses: "200": - description: Enabled collection update response + description: Available theme list content: application/json: schema: @@ -1197,6 +1242,11 @@ paths: - settings operationId: saveEnabledThemesPatch summary: Update enabled theme configuration using the themes collection endpoint + description: > + Write operation — updates enabled theme configuration. Single-user + deployment assumption: the NodeJS backend does not model an + admin/superUser tier; settings are written by the single authenticated + dashboard user. security: - bearerAuth: [] userTokenHeader: [] @@ -2448,13 +2498,30 @@ components: type: string LoginRequest: type: object + description: > + Login credentials or JWT revalidation token. When username/password are + supplied the server validates them and issues a fresh JWT. When jwt is + supplied (body or query) the server revalidates the existing token and + returns it if still valid, enabling a revalidate-without-credentials + login flow. properties: + username: + type: string + description: Username (primary field name accepted by v1 handlers) + password: + type: string + description: Password u: type: string - description: Username + description: Legacy username alias (v0 compatibility) p: type: string - description: Password + description: Legacy password alias (v0 compatibility) + jwt: + type: string + description: > + Existing JWT to revalidate. When supplied (body or query) the + server validates the token and returns it if still valid. additionalProperties: true SiteActionRequest: type: object diff --git a/src/siteRoutes/v1/blocks.js b/src/siteRoutes/v1/blocks.js index c43e0fdc..38e299a7 100644 --- a/src/siteRoutes/v1/blocks.js +++ b/src/siteRoutes/v1/blocks.js @@ -340,7 +340,11 @@ async function listBlocks(req, res) { const filterTag = String(getQueryValue(req, 'filter.tag', '') || '') .trim() .toLowerCase(); - const filteredItems = applyItemFilters(getOrderedItems(site), req, site); + // A4: enforce anon-visibility so anon callers don't see usage from + // unpublished/hidden items + const filteredItems = applyItemFilters(getOrderedItems(site), req, site, { + enforceAnonymousVisibility: true, + }); const usage = await collectCustomElementUsage(site, filteredItems); const wcMap = HAXCMS.getWCRegistryJson(site); const autoloader = getAutoloaderList(); @@ -419,7 +423,11 @@ async function blockDetail(req, res) { const apiBasePath = getApiBasePath(req); const include = getCsvQuery(req, 'include'); const fields = getCsvQuery(req, 'fields'); - const orderedItems = getOrderedItems(site); + // A4: enforce anon-visibility so anon callers don't see usage details from + // unpublished/hidden items + const orderedItems = applyItemFilters(getOrderedItems(site), req, site, { + enforceAnonymousVisibility: true, + }); const wcMap = HAXCMS.getWCRegistryJson(site); const enabledBlocks = await readEnabledBlocksSetting(); const enabledBlockSet = new Set(Array.isArray(enabledBlocks) ? enabledBlocks : []); @@ -489,7 +497,11 @@ async function blockUsage(req, res) { } const apiBasePath = getApiBasePath(req); const fields = getCsvQuery(req, 'fields'); - const filteredItems = applyItemFilters(getOrderedItems(site), req, site); + // A4: enforce anon-visibility so anon callers don't see usage from + // unpublished/hidden items + const filteredItems = applyItemFilters(getOrderedItems(site), req, site, { + enforceAnonymousVisibility: true, + }); const usageTotals = await collectCustomElementUsage(site, filteredItems); const wcMap = HAXCMS.getWCRegistryJson(site); if (!isKnownBlockTag(webcomponentName, wcMap, usageTotals)) { diff --git a/src/siteRoutes/v1/content.js b/src/siteRoutes/v1/content.js index b84309f9..f5217013 100644 --- a/src/siteRoutes/v1/content.js +++ b/src/siteRoutes/v1/content.js @@ -19,6 +19,7 @@ const { ensureRequestBodyObject, getRequestHeaderValue, getSiteNameFromResolvedSite, + escapeHtmlValue, } = require('./siteRouteUtils.js'); const saveNodeRoute = require('./routes/saveNode.js'); const siteSearchRoute = require('./routes/siteSearch.js'); @@ -57,8 +58,14 @@ function buildConcatHtml(records = []) { const sections = []; for (let i = 0; i < records.length; i++) { const record = records[i]; + // E5: escape record.id and record.title to prevent output-injection from + // authored titles; record.body is intentional HTML and must not be escaped + const safeId = escapeHtmlValue(record.id || ''); + const safeTitle = escapeHtmlValue( + record.title || record.slug || record.id || 'Untitled', + ); sections.push( - `

${record.title || record.slug || record.id || 'Untitled'}

${record.body || ''}
`, + `

${safeTitle}

${record.body || ''}
`, ); } return sections.join('\n'); diff --git a/src/siteRoutes/v1/exports.js b/src/siteRoutes/v1/exports.js index 5d0cb272..8341853e 100644 --- a/src/siteRoutes/v1/exports.js +++ b/src/siteRoutes/v1/exports.js @@ -746,8 +746,8 @@ async function siteExport(req, res) { status: 400, data: { message: `Unsupported site export format "${format}"`, + supportedFormats: SITE_EXPORT_FORMATS, }, - supportedFormats: SITE_EXPORT_FORMATS, }) } const ancestor = getQueryValue(req, 'filter.ancestor', '') @@ -797,9 +797,14 @@ async function siteExport(req, res) { if (format === 'html') { try { const html = await buildSiteExportHtml(site, ancestor, magic) - res.status(200) - res.setHeader('Content-Type', 'text/html; charset=utf-8') - return res.send(html) + // E4: send html export as a file download with Content-Disposition + // (mirrors PHP sendFileDownload / other binary export formats) + return sendDownloadResponse( + res, + Buffer.from(html), + 'text/html; charset=utf-8', + `${getSiteExportFileBaseName(site)}.html`, + ) } catch (e) { return res.status(500).json({ @@ -871,8 +876,8 @@ async function itemExport(req, res) { status: 400, data: { message: `Unsupported item export format "${format}"`, + supportedFormats: ITEM_EXPORT_FORMATS, }, - supportedFormats: ITEM_EXPORT_FORMATS, }) } const apiBasePath = getApiBasePath(req) @@ -1020,8 +1025,8 @@ async function siteExportMutation(req, res) { status: 400, data: { message: `Unsupported site export format "${format}"`, + supportedFormats: SITE_EXPORT_FORMATS, }, - supportedFormats: SITE_EXPORT_FORMATS, }); } const exportDetails = buildSiteExportDetails(site, apiBasePath, format); diff --git a/src/siteRoutes/v1/files.js b/src/siteRoutes/v1/files.js index c821f7e7..04482019 100644 --- a/src/siteRoutes/v1/files.js +++ b/src/siteRoutes/v1/files.js @@ -138,7 +138,8 @@ function getDateCreatedValue(entryStats) { if (createdMs <= 0) { return 0; } - return Math.round(createdMs); + // E1: dateCreated in SECONDS (matches metadata.updated), not milliseconds + return Math.floor(createdMs / 1000); } function getSiteNameForFileUuid(site) { diff --git a/src/siteRoutes/v1/views.js b/src/siteRoutes/v1/views.js index 4e343942..19a0f1a7 100644 --- a/src/siteRoutes/v1/views.js +++ b/src/siteRoutes/v1/views.js @@ -13,6 +13,8 @@ const { normalizeTagList, getItemContent, sendFormattedResponse, + isAnonymousSiteApiRequest, + isItemVisibleToAnonymous, } = require('./siteRouteUtils.js'); function normalizeStoredViews(site, apiBasePath) { @@ -149,7 +151,11 @@ async function resolveViewResults(view, site, req, apiBasePath) { : 'items'; if (source === 'tags') { const tagMap = {}; - const items = getOrderedItems(site); + let items = getOrderedItems(site); + // A4: anon callers must not see tags from unpublished/hidden items + if (isAnonymousSiteApiRequest(req)) { + items = items.filter((item) => isItemVisibleToAnonymous(item)); + } for (let i = 0; i < items.length; i++) { const item = items[i]; const tags = normalizeTagList(item && item.metadata ? item.metadata.tags : []); @@ -157,10 +163,13 @@ async function resolveViewResults(view, site, req, apiBasePath) { tagMap[tags[t]] = (tagMap[tags[t]] || 0) + 1; } } - return Object.keys(tagMap).map((tag) => ({ + let tagRecords = Object.keys(tagMap).map((tag) => ({ tag, count: tagMap[tag], })); + // E6: honor ?sort for tags source (default '-count'), matching PHP + tagRecords = sortRecords(tagRecords, getQueryValue(req, 'sort', ''), '-count'); + return tagRecords; } if (source === 'search') { const query = @@ -170,7 +179,11 @@ async function resolveViewResults(view, site, req, apiBasePath) { return []; } const queryLower = query.toLowerCase(); - const items = getOrderedItems(site); + let items = getOrderedItems(site); + // A4: anon callers must not search unpublished/hidden items + if (isAnonymousSiteApiRequest(req)) { + items = items.filter((item) => isItemVisibleToAnonymous(item)); + } const results = []; for (let i = 0; i < items.length; i++) { const item = items[i]; @@ -187,7 +200,11 @@ async function resolveViewResults(view, site, req, apiBasePath) { } let items = getOrderedItems(site); items = applyViewQueryFilters(items, view.query); - items = applyItemFilters(items, req, site); + // A4: pass anon-visibility enforcement so anon callers don't see + // unpublished/hidden items (same as /v1/search and /v1/items) + items = applyItemFilters(items, req, site, { + enforceAnonymousVisibility: true, + }); let records = items.map((item) => itemToSummary(item, apiBasePath)); const viewSort = view && view.query && view.query.sort ? String(view.query.sort) : 'order'; diff --git a/src/systemRoutes/v1/routes/archiveSite.js b/src/systemRoutes/v1/routes/archiveSite.js index afd5dcf8..f3930597 100644 --- a/src/systemRoutes/v1/routes/archiveSite.js +++ b/src/systemRoutes/v1/routes/archiveSite.js @@ -35,21 +35,35 @@ const { HAXCMS } = require('../../../lib/HAXCMS.js'); * ) * ) */ - async function archiveSite(req, res) { + async function archiveSite(req, res) { let site = await HAXCMS.loadSite(req.body['site']['name']); if (site.name) { // create archived directory in this tree if it doesn't exist already if (!fs.existsSync(HAXCMS.HAXCMS_ROOT + HAXCMS.archivedDirectory)) { fs.mkdirSync(HAXCMS.HAXCMS_ROOT + HAXCMS.archivedDirectory); } + // D5: uniquify collided archive names (name-1, name-2…) so a bare + // rename doesn't fail when the destination already exists. Mirrors + // PHP routes/archiveSite.php:49-71. + const baseArchiveName = site.manifest.metadata.site.name; + let archivedName = baseArchiveName; + let counter = 1; + while ( + fs.existsSync( + HAXCMS.HAXCMS_ROOT + HAXCMS.archivedDirectory + '/' + archivedName, + ) + ) { + archivedName = baseArchiveName + '-' + counter; + counter++; + } await fs.rename( - HAXCMS.HAXCMS_ROOT + HAXCMS.sitesDirectory + '/' + site.manifest.metadata.site.name, - HAXCMS.HAXCMS_ROOT + HAXCMS.archivedDirectory + '/' + site.manifest.metadata.site.name); + HAXCMS.HAXCMS_ROOT + HAXCMS.sitesDirectory + '/' + baseArchiveName, + HAXCMS.HAXCMS_ROOT + HAXCMS.archivedDirectory + '/' + archivedName); res.send({ status: 200, data: { name: site.name, - archivedName: site.name, + archivedName: archivedName, detail: 'Site archived', }, }); diff --git a/src/systemRoutes/v1/routes/connectionSettings.js b/src/systemRoutes/v1/routes/connectionSettings.js index c6e279c3..f1382e93 100644 --- a/src/systemRoutes/v1/routes/connectionSettings.js +++ b/src/systemRoutes/v1/routes/connectionSettings.js @@ -136,6 +136,11 @@ function resolveSystemOperationPath( * ) */ async function connectionSettings(req, res) { + // D6: token/JWT-bearing JS — prevent any intermediary from caching + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.setHeader('Surrogate-Control', 'no-store'); res.setHeader('Content-Type', 'application/javascript'); const isDashboardRequest = ( HAXCMS && diff --git a/src/systemRoutes/v1/routes/downloadSite.js b/src/systemRoutes/v1/routes/downloadSite.js index f099ff96..fad0b6bd 100644 --- a/src/systemRoutes/v1/routes/downloadSite.js +++ b/src/systemRoutes/v1/routes/downloadSite.js @@ -123,7 +123,15 @@ function zipDirectory(sourceDir, outPath) { return new Promise((resolve, reject) => { archive - .directory(sourceDir, false) + .glob('**/*', { + cwd: sourceDir, + ignore: [ + 'node_modules/**', + 'node_modules', + '.git/**', + '.git', + ], + }) .on('error', err => reject(err)) .pipe(stream) ; diff --git a/src/systemRoutes/v1/routes/getUserData.js b/src/systemRoutes/v1/routes/getUserData.js index 57917aaf..31b5c2b6 100644 --- a/src/systemRoutes/v1/routes/getUserData.js +++ b/src/systemRoutes/v1/routes/getUserData.js @@ -16,20 +16,6 @@ const { HAXCMS } = require('../../../lib/HAXCMS.js'); * ) * ) */ -function getUserTokenFromHeader(req) { - if (!req || !req.headers || typeof req.headers !== 'object') { - return ''; - } - const rawValue = req.headers['x-haxcms-user-token']; - if (Array.isArray(rawValue)) { - return rawValue.length > 0 ? String(rawValue[0] || '').trim() : ''; - } - if (typeof rawValue === 'string') { - return rawValue.trim(); - } - return ''; -} - function getUserData(req, res) { const returnData = { status: 200, diff --git a/src/systemRoutes/v1/routes/login.js b/src/systemRoutes/v1/routes/login.js index 0429ff3a..367ebab6 100644 --- a/src/systemRoutes/v1/routes/login.js +++ b/src/systemRoutes/v1/routes/login.js @@ -20,14 +20,25 @@ function loginRoute(req, res) { if (retryAfterSeconds > 0) { res.set('Retry-After', String(retryAfterSeconds)); } - return res.sendStatus(429); + // D2: JSON D1 envelope (was sendStatus plain-text) + return res.status(429).json({ + status: 429, + data: { + message: + 'Too many failed login attempts. Please try again later.', + }, + }); } // test if this is a valid user login if (!HAXCMS.testLogin(u, p, true)) { if (settings.enabled) { registerFailedAttempt(entry, now, settings); } - return res.sendStatus(403); + // D2/Q8: login failure returns 401 (was 403) with JSON D1 envelope + return res.status(401).json({ + status: 401, + data: { message: 'Invalid username or password' }, + }); } clearTrackerEntry(attemptKey); // set a refresh_token COOKIE that will ship w/ all calls automatically @@ -52,10 +63,18 @@ function loginRoute(req, res) { jwt: valid, }); } - return res.sendStatus(403); + // D2/Q8: JWT revalidate failure returns 401 (was 403) with JSON envelope + return res.status(401).json({ + status: 401, + data: { message: 'Invalid or expired token' }, + }); } else { - res.sendStatus(403); + // D2/Q8: no credentials supplied returns 401 (was 403) with JSON envelope + res.status(401).json({ + status: 401, + data: { message: 'Authentication required' }, + }); } }