diff --git a/.ai/wheels/cross-engine-compatibility.md b/.ai/wheels/cross-engine-compatibility.md index d4d9724325..8c7430bdb0 100644 --- a/.ai/wheels/cross-engine-compatibility.md +++ b/.ai/wheels/cross-engine-compatibility.md @@ -36,6 +36,38 @@ plugin.onPluginLoad(context); **Why**: Adobe's application scope is implemented differently from a regular CFML struct. Function members get lost or throw errors during serialization. +### Application Scope Unreliable During onApplicationEnd() Teardown (Adobe CF 2023) + +On Adobe CF 2023, `onApplicationEnd()` fires synchronously during `applicationStop()` teardown (triggered by a `?reload` restart or idle-timeout reclaim). Inside that teardown the live `application` scope is no longer reliable — bare `application.wo` can resolve against a stale/torn-down scope and land on a Java `String[]`, throwing `Element wo is undefined in a Java object of type class [Ljava.lang.String;` and erroring the whole site until a CF service restart. + +The only dependable reference at shutdown is the passed-in `arguments.applicationScope` (already used by the `$wheelsBrowserLauncher` cleanup in the same handler). Route all `onApplicationEnd()` calls through it and guard with `StructKeyExists` so a partially reclaimed scope degrades to a no-op instead of a hard error. Lucee 6/7 and BoxLang are unaffected; this only manifests on Adobe CF during real teardown (issue #3379). + +```cfm +// WRONG — bare application.wo breaks during Adobe CF applicationStop() teardown +public void function onApplicationEnd(struct ApplicationScope) { + application.wo.$include( + template = "../../#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", + argumentCollection = arguments + ); +} + +// RIGHT — use the passed-in scope, the only reliable reference at shutdown +public void function onApplicationEnd(struct ApplicationScope) { + if ( + StructKeyExists(arguments.applicationScope, "wo") + && StructKeyExists(arguments.applicationScope, "wheels") + && StructKeyExists(arguments.applicationScope.wheels, "eventPath") + ) { + arguments.applicationScope.wo.$include( + template = "../../#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", + argumentCollection = arguments + ); + } +} +``` + +**Existing apps**: apply this same change to `public/Application.cfc` — the CLI template (`wheels new`) and the demo app were fixed in Wheels 4.x (#3380). + ### Closure `this` Captures Declaring Scope CFML closures bind `this` to the component where they are DEFINED, not where they are ASSIGNED. This trips up test code that dynamically adds methods. @@ -347,6 +379,42 @@ public void function $myTagWrapper() { **Reference fix**: [#2756](https://github.com/wheels-dev/wheels/pull/2756) — adds `$responseCommitted()` and applies the defensive shape to `$header()` and `$content()`. +### A Parameter Named `request` Makes the Bare `request` Token Ambiguous (Adobe CF 2025) + +In a function that declares a parameter named `request`, Adobe CF 2025 does **not** resolve the bare `request` token consistently across expression positions. Passed as a function argument it can resolve to the built-in `request` scope, while a `request.x` member-access expression in the same function resolves to `arguments.request`. A guard written in one position therefore cannot protect an access written in the other — the guard passes and the access throws. + +```cfm +public string function handle(required struct request, required any next) { + + // WRONG — the two `request` tokens can resolve to different things on Adobe 2025. + // StructKeyExists sees the key on the built-in scope and returns true; the + // member-access expression then resolves to arguments.request, which has no + // `wheels` key, and throws `Element WHEELS is undefined in REQUEST`. + if (StructKeyExists(request, "wheels")) { + StructDelete(request.wheels, "tenant"); + } + + // RIGHT (a) — IsDefined string-resolves the whole dotted path in a single + // evaluation, so the guard and the access cannot disagree. + if (IsDefined("request.wheels.tenant")) { + StructDelete(request.wheels, "tenant"); + } + + // RIGHT (b) — assign before use, so the key exists on that path regardless + // of how the token resolved. + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + } + request.wheels.tenant = local.tenant; +} +``` + +**Why**: `wheels.middleware.MiddlewareInterface` fixes the signature `handle(required struct request, required any next)`, so *every* middleware component has a parameter named `request` and is exposed to this. Lucee 6/7, BoxLang and Adobe CF 2023 all resolve the bare token to the built-in scope in both positions, so **a green local Lucee run and green Adobe 2023 smokes do NOT cover this** — only the Adobe 2025 matrix legs catch it. + +Note the interaction with anti-pattern 11 (reserved scope names shadowing parameters): that entry says never *name* a parameter after a reserved scope. Middleware can't follow that rule — the interface mandates it — so middleware must instead follow the two safe patterns above and never mix them with a bare-token guard. + +**Reference fix**: [#3338](https://github.com/wheels-dev/wheels/pull/3338) — the tenant-context hardening for [#3336](https://github.com/wheels-dev/wheels/issues/3336) added a `StructKeyExists(request, "wheels")` guard to `TenantResolver.handle()`; it errored on all five Adobe 2025 database legs (8 specs each) while every other engine stayed green, and was switched to the `IsDefined()` form already used by the same function's `finally` block. + ## Database-Specific Gotchas ### H2 Database (Test Default) diff --git a/.ai/wheels/snippets/model-snippets.md b/.ai/wheels/snippets/model-snippets.md index db919550ba..bb26e3c118 100755 --- a/.ai/wheels/snippets/model-snippets.md +++ b/.ai/wheels/snippets/model-snippets.md @@ -151,10 +151,17 @@ function recent(days=30) { ## Calculated Properties ```cfm function config() { - // SQL-based calculated property + // SQL-based calculated property — included in every SELECT by default property(name="orderTotal", sql="(SELECT SUM(amount) FROM order_items WHERE order_id = orders.id)"); + + // Keep off the hot path with select=false; opt in per-call with includeCalculated + property(name="fullName", sql="firstName || ' ' || lastName", select=false); } +// Opt a select=false property back into one finder (additive — base columns still selected) +user = model("User").findOne(includeCalculated="fullName"); +order = model("Order").findAll(includeCalculated="orderTotal,shippingCost"); + // Method-based calculated property function displayName() { if (Len(this.nickName)) { diff --git a/.ai/wheels/testing/browser-testing.md b/.ai/wheels/testing/browser-testing.md index 5592f6a020..b755661f8a 100644 --- a/.ai/wheels/testing/browser-testing.md +++ b/.ai/wheels/testing/browser-testing.md @@ -65,5 +65,6 @@ bash tools/test-local.sh # skips browser specs if JARs missin - **Data URLs work for most tests** — no server needed for ~95% of DSL coverage. Full HTTP integration (cookies, form submits, redirects) needs a running fixture app; that wiring is the same as Wheels Web app bootstrap (separate server + baseUrl). - **`this.browserTestSkipped`** — when Playwright JARs aren't installed (fresh CI, clean machine), `beforeAll` sets this flag and `browserDescribe`'s hooks short-circuit. All `it`s should check `if (this.browserTestSkipped) return;` to stay green on CI. - **CI runs browser tests** — `pr.yml` and `snapshot.yml` install Playwright JARs + Chromium (cached via `browser-manifest.json` hash). Browser specs run as part of the normal test suite. `WHEELS_BROWSER_TEST_BASE_URL=http://localhost:60007` is set automatically. The base URL is resolved at instance time through a layered lookup (`this.baseUrl` → Wheels setting → JVM property `wheels.browserTest.baseUrl` → env var → CGI auto-detect → `http://localhost:8080`); per-spec `this.baseUrl` takes priority over the env var. Set `this.baseUrl` in the component pseudo-constructor (outside any function), not inside `beforeAll()` — `super.beforeAll()` calls `$resolveBaseUrl()` and caches the result, so a `this.baseUrl =` assignment that runs after `super.beforeAll()` is silently ignored. +- **Isolated application context (#3374)** — `BrowserTest.$startBrowserContext()` sends `X-Wheels-Test-Context` (Playwright `extraHTTPHeaders`) plus a `WHEELS_TEST_CONTEXT` cookie so fixture HTTP binds `_wheelsTest`, not the live app. `Application.cfc` must include `vendor/wheels/events/testcontext.cfm` after `config/app.cfm` (ships in `wheels new`). Without that snippet, browser tests still work via the #3373 live-scope swap. - **Fixture routes** — `/_browser/login-as` and `/_browser/logout` are mounted automatically in test mode. They must come before `.wildcard()` in routes.cfm. In the Routes UI (`/wheels/routes`) all `/_browser/*` routes appear under the **Internal** tab, not Application. The `/_browser/login-as` handler is configurable: `set(browserLoginAsHandler = "AuthFixture##loginAs")` in `config/settings.cfm` substitutes that `Controller##action` at route-registration time (default is `BrowserTestLogin##create`). Env-gating is handled by `wheels.middleware.BrowserTestFixtureGuard` on the whole `/_browser` scope — custom handlers do not need to re-implement the guard. Empty string or absent setting falls back to the default. (#2830) - **Dialogs are Lucee-only** — `acceptDialog`, `dismissDialog`, `dialogMessage` use `createDynamicProxy` which is Lucee-specific. Specs skip gracefully on other engines. diff --git a/.claude/workflows/triage-discussions.js b/.claude/workflows/triage-discussions.js new file mode 100644 index 0000000000..dd12416a6e --- /dev/null +++ b/.claude/workflows/triage-discussions.js @@ -0,0 +1,179 @@ +export const meta = { + name: 'triage-discussions', + description: 'Fetch recent GitHub Discussions, triage each thread, and adversarially verify which ones actually need a code fix, doc update, or new issue', + whenToUse: 'Periodic (e.g. monthly) sweep of community GitHub Discussions to surface genuinely actionable items — without filing duplicates or acting on reports that are already fixed/answered. Read-only: produces a verified findings report, posts nothing.', + phases: [ + { title: 'Fetch', detail: 'pull + filter recent discussions (drop Announcements and bot reports)' }, + { title: 'Triage', detail: 'read each discussion thread, classify, propose action', model: 'sonnet' }, + { title: 'Verify', detail: 'adversarially check each proposed action against current code + open issues' }, + ], +} + +// --------------------------------------------------------------------------- +// args (all optional). Accepts an object, a JSON string, or a bare date string: +// { repo: "owner/name", since: "YYYY-MM-DD", max: 40 } +// "2026-01-01" -> treated as { since } +// Defaults: repo=wheels-dev/wheels, since=2025-01-01, max=40 +// --------------------------------------------------------------------------- +const opts = (() => { + if (args == null) return {} + if (typeof args === 'string') { + const s = args.trim() + if (s.startsWith('{')) { try { return JSON.parse(s) } catch (e) { return {} } } + return s ? { since: s } : {} + } + return args +})() +const REPO = opts.repo || 'wheels-dev/wheels' +const SINCE = opts.since || '2025-01-01' +const MAX = opts.max || 40 +const [OWNER, NAME] = REPO.split('/') + +const CANDIDATES_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + candidates: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + number: { type: 'integer' }, + title: { type: 'string' }, + category: { type: 'string' }, + comments: { type: 'integer' }, + isAnswered: { type: 'boolean' }, + updatedAt: { type: 'string' }, + author: { type: 'string' }, + }, + required: ['number', 'title', 'category', 'comments', 'isAnswered', 'updatedAt', 'author'], + }, + }, + }, + required: ['candidates'], +} + +const TRIAGE_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + number: { type: 'integer' }, + classification: { type: 'string', enum: ['bug', 'feature-idea', 'doc-gap', 'question-unanswered', 'question-answered', 'onboarding-friction', 'infra', 'noise'] }, + summary: { type: 'string' }, + resolvedInThread: { type: 'boolean' }, + proposedAction: { type: 'string', enum: ['code-fix', 'doc-update', 'file-issue', 'reply', 'close-stale', 'none'] }, + actionDetail: { type: 'string' }, + severity: { type: 'string', enum: ['high', 'medium', 'low'] }, + needsVerification: { type: 'boolean' }, + }, + required: ['number', 'classification', 'summary', 'resolvedInThread', 'proposedAction', 'actionDetail', 'severity', 'needsVerification'], +} + +const VERDICT_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + number: { type: 'integer' }, + verdict: { type: 'string', enum: ['confirmed-needs-action', 'already-fixed', 'already-tracked', 'wont-act', 'insufficient-evidence'] }, + evidence: { type: 'string' }, + existingIssue: { type: 'string' }, + recommendedAction: { type: 'string' }, + confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, + }, + required: ['number', 'verdict', 'evidence', 'existingIssue', 'recommendedAction', 'confidence'], +} + +function fetchCmd(num) { + return `gh api graphql -f query='query($num: Int!) { repository(owner:"${OWNER}", name:"${NAME}") { discussion(number:$num) { title url createdAt updatedAt category{name} answer{ body author{login} } body comments(first:50){ nodes{ author{login} createdAt body replies(first:20){ nodes{ author{login} body } } } } } } }' -F num=${num}` +} + +const fetchPrompt = [ + `Build the candidate list of GitHub Discussions to triage for repo ${REPO}.`, + ``, + `Run this in Bash (the API returns the 100 most-recently-updated; that is enough for a recency sweep):`, + `gh api graphql -f query='query { repository(owner:"${OWNER}", name:"${NAME}") { discussions(first:100, orderBy:{field:UPDATED_AT, direction:DESC}) { nodes { number title url createdAt updatedAt isAnswered category{name} comments{totalCount} author{login} } } } }'`, + ``, + `Filter the nodes:`, + `- EXCLUDE category == "Announcements" (maintainer marketing / release posts — not actionable).`, + `- EXCLUDE author login "github-actions" (automated monthly metrics reports).`, + `- KEEP only discussions whose updatedAt >= ${SINCE}.`, + `Sort the kept set by updatedAt descending and return at most ${MAX}.`, + ``, + `For each kept discussion emit: number, title, category (name), comments (the comments.totalCount integer), isAnswered (use false when the GraphQL value is null), updatedAt (date only, "YYYY-MM-DD"), author (login).`, + `Return ONLY the structured object {candidates: [...]}.`, +].join('\n') + +function triagePrompt(c) { + return [ + `You are triaging a GitHub Discussion in the Wheels CFML framework repo (${REPO}). Wheels is on the develop branch; many older threads reference 3.x.`, + `Discussion #${c.number}: "${c.title}" — category=${c.category}, comments=${c.comments}, updated=${c.updatedAt}, author=${c.author}.`, + ``, + `STEP 1 — Read the FULL thread. Run this in Bash:`, + fetchCmd(c.number), + ``, + `STEP 2 — Understand what the user reports or asks, and whether the thread already resolved it (accepted answer, "thanks that worked", maintainer fix linked, etc).`, + ``, + `STEP 3 — Classify and decide whether the maintainer needs to DO something NOW.`, + `- classification: bug | feature-idea | doc-gap | question-unanswered | question-answered | onboarding-friction | infra | noise`, + `- proposedAction: code-fix | doc-update | file-issue | reply | close-stale | none`, + `- needsVerification: TRUE when the proposed action depends on the current state of the codebase or on whether an issue already exists (any code-fix / file-issue / doc-update claim a downstream verifier must confirm against HEAD). FALSE for pure social replies, clear no-ops, or answered questions that revealed nothing latent.`, + ``, + `Be conservative: an answered Q&A with an accepted answer usually needs no action UNLESS it exposes a real doc gap or a latent bug. A "broken"/"doesn't work" title is a CLAIM, not a confirmed bug — flag it for verification rather than asserting it.`, + `summary: 1-2 sentences. actionDetail: the concrete next step (or "none"). severity: high|medium|low (impact on users).`, + `Return ONLY the structured object.`, + ].join('\n') +} + +function verifyPrompt(t, c) { + return [ + `You are an ADVERSARIAL verifier for the Wheels CFML framework (${REPO}); working tree is the develop branch. DEFAULT TO SKEPTICAL — assume the proposed action is unnecessary until evidence proves otherwise.`, + `A triage agent reviewed Discussion #${c.number} ("${c.title}") and proposed: ${t.proposedAction} — "${t.actionDetail}" (classification: ${t.classification}, severity: ${t.severity}).`, + `Thread summary: ${t.summary}`, + ``, + `Verify against reality, citing evidence:`, + `1. ALREADY FIXED? Search the codebase (Grep/Glob/Read) and recent history (git log --oneline -40). The reported behavior may already be patched. Re-read the thread if useful: ${fetchCmd(c.number)}`, + `2. ALREADY TRACKED? Run: gh issue list --repo ${REPO} --state all --search "" --limit 15 (try a couple of keyword sets).`, + `3. Only if it is a REAL, untracked, unfixed problem -> confirmed-needs-action.`, + ``, + `verdict: confirmed-needs-action | already-fixed | already-tracked | wont-act | insufficient-evidence`, + `evidence: cite a file:line, commit SHA, or issue number that justifies the verdict (be specific). existingIssue: issue number if already-tracked, else "".`, + `recommendedAction: the crisp final recommendation for the maintainer. confidence: high|medium|low.`, + `READ-ONLY: do NOT post anything to GitHub, do NOT edit files. Return ONLY the structured object.`, + ].join('\n') +} + +phase('Fetch') +const fetched = await agent(fetchPrompt, { label: 'fetch-discussions', phase: 'Fetch', schema: CANDIDATES_SCHEMA, effort: 'low' }) +const candidates = (fetched && fetched.candidates) || [] +log(`Fetched ${candidates.length} candidate discussions (updated since ${SINCE}, excluding Announcements + bots).`) +if (!candidates.length) return { reviewed: 0, confirmedCount: 0, findings: [] } + +phase('Triage') +const results = await pipeline( + candidates, + (c) => agent(triagePrompt(c), { label: `triage:#${c.number}`, phase: 'Triage', schema: TRIAGE_SCHEMA, model: 'sonnet', effort: 'medium' }), + (t, c) => { + if (!t) return { number: c.number, candidate: c, triage: null, verdict: null } + const actionable = t.needsVerification && t.proposedAction !== 'none' && t.classification !== 'noise' + if (!actionable) { + return { + number: c.number, candidate: c, triage: t, + verdict: { + number: c.number, verdict: 'wont-act', + evidence: 'triage: no codebase/issue verification needed', + existingIssue: '', + recommendedAction: t.proposedAction === 'none' ? 'No action needed' : t.actionDetail, + confidence: 'medium', skippedVerify: true, + }, + } + } + return agent(verifyPrompt(t, c), { label: `verify:#${c.number}`, phase: 'Verify', effort: 'high', schema: VERDICT_SCHEMA }) + .then(v => ({ number: c.number, candidate: c, triage: t, verdict: v })) + } +) + +const findings = results.filter(Boolean) +const confirmed = findings.filter(f => f.verdict && f.verdict.verdict === 'confirmed-needs-action') +log(`Done: ${findings.length} reviewed, ${confirmed.length} confirmed as needing action.`) +return { reviewed: candidates.length, confirmedCount: confirmed.length, findings } diff --git a/.github/actions/setup-wheels-test-env/action.yml b/.github/actions/setup-wheels-test-env/action.yml index 4c819a9065..97a7860a89 100644 --- a/.github/actions/setup-wheels-test-env/action.yml +++ b/.github/actions/setup-wheels-test-env/action.yml @@ -19,7 +19,7 @@ inputs: lucli-version: description: 'LuCLI release version' required: false - default: '0.3.7' + default: '0.6.1' install-playwright: description: 'Install Playwright + Chromium (set "false" to skip if no browser tests run)' required: false diff --git a/.github/workflows/commandbox-install-smoke.yml b/.github/workflows/commandbox-install-smoke.yml index df78ec3430..ac8703a268 100644 --- a/.github/workflows/commandbox-install-smoke.yml +++ b/.github/workflows/commandbox-install-smoke.yml @@ -80,8 +80,27 @@ jobs: # Pin the version the prepare scripts stamp into the artifacts. PKG_VERSION: "0.0.0-cismoke" steps: + # The CommandBox image ships without git, and actions/checkout without git + # falls back to the REST-API tarball — which honors .gitattributes + # export-ignore, and this repo export-ignores tools/ and cli/. That left + # this job's workspace without the very build scripts and templates it + # exists to test (prepare-base.sh: No such file or directory, exit 127, + # on every run since the job was added). Install git BEFORE checkout so + # it performs a real clone. + - name: Ensure git is available (checkout must not fall back to the export-ignored tarball) + run: | + command -v git >/dev/null 2>&1 || { + apt-get update -y && apt-get install -y --no-install-recommends git ca-certificates + } + git --version + - uses: actions/checkout@v5 + - name: Assert the export-ignored paths actually checked out + run: | + test -f tools/build/scripts/prepare-base.sh || { + echo "::error::tools/ missing from workspace — checkout fell back to the export-ignored tarball again"; exit 1; } + # curl + jq are the only host tools the probes need; the CommandBox image # is Debian-based but minimal. Install defensively (no-op if present). - name: Ensure curl is available diff --git a/.github/workflows/compat-matrix.yml b/.github/workflows/compat-matrix.yml index 82d61a4d68..7243d711bb 100644 --- a/.github/workflows/compat-matrix.yml +++ b/.github/workflows/compat-matrix.yml @@ -15,7 +15,6 @@ jobs: tests: name: "${{ matrix.cfengine }}" runs-on: ubuntu-latest - continue-on-error: true strategy: fail-fast: false matrix: @@ -439,12 +438,40 @@ jobs: " || { echo "JUnit conversion failed for ${db} (non-fatal)"; rm -f "$JUNIT_FILE"; } fi + # Zero-test guard (#3302): a compile-wiped leg returns HTTP 200 with + # totalSpecs=0 (one bad CFC zeroes the whole directory compile), which + # previously rendered as a pass. Every engine runs the same core suite + # (~4,700 specs), so anything below the floor means the suite never + # actually ran. Revisit the floor if per-DB spec subsets ever ship. + MIN_SPECS=4000 + TOTAL_SPECS="-1" + if [ -f "$RESULT_FILE" ] && { [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "417" ]; }; then + TOTAL_SPECS=$(python3 -c " + import json, sys + try: + d = json.load(open('$RESULT_FILE')) + print(int(d.get('totalSpecs', 0))) + except: + print(-1) + " 2>/dev/null || echo "-1") + fi + + SPECS_OK=true + if { [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "417" ]; } && [ "$TOTAL_SPECS" -lt "$MIN_SPECS" ]; then + SPECS_OK=false + echo "::error::${{ matrix.cfengine }} + ${db}: HTTP ${HTTP_CODE} but only ${TOTAL_SPECS} testcases reported (floor: ${MIN_SPECS}) — suite likely compile-wiped, treating leg as failed" + fi + # Track per-database result - if [ "$HTTP_CODE" = "200" ]; then - echo "PASSED: ${{ matrix.cfengine }} + ${db}" + if [ "$HTTP_CODE" = "200" ] && [ "$SPECS_OK" = true ]; then + echo "PASSED: ${{ matrix.cfengine }} + ${db} (${TOTAL_SPECS} testcases)" DB_STATUS="pass" else - echo "FAILED: ${{ matrix.cfengine }} + ${db} (HTTP ${HTTP_CODE})" + if [ "$HTTP_CODE" = "200" ]; then + echo "FAILED: ${{ matrix.cfengine }} + ${db} (HTTP 200 but zero-test guard tripped)" + else + echo "FAILED: ${{ matrix.cfengine }} + ${db} (HTTP ${HTTP_CODE})" + fi DB_STATUS="fail" if echo "$SOFT_FAIL_DBS" | grep -qw "$db"; then echo "::warning::${db} tests failed but marked as soft-fail (non-blocking)" @@ -486,6 +513,8 @@ jobs: echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY SOFT_FAIL_DBS="oracle" + # Keep in sync with MIN_SPECS in the run-tests step (#3302). + MIN_SPECS=4000 IFS=',' read -ra DBS <<< "${{ steps.db-list.outputs.databases }}" for db in "${DBS[@]}"; do RESULT_FILE="/tmp/test-results/${{ matrix.cfengine }}-${db}-result.txt" @@ -494,18 +523,22 @@ jobs: IS_SOFT_FAIL=true fi if [ -f "$RESULT_FILE" ]; then - # Check JSON for failures - FAIL_COUNT=$(python3 -c " + # Check JSON for failures and testcase count (zero-test guard, #3302) + STATS=$(python3 -c " import json, sys try: d = json.load(open('$RESULT_FILE')) - print(d.get('totalFail', 0) + d.get('totalError', 0)) + print(int(d.get('totalFail', 0) + d.get('totalError', 0)), int(d.get('totalSpecs', 0))) except: - print(-1) - " 2>/dev/null || echo "-1") + print(-1, -1) + " 2>/dev/null || echo "-1 -1") + FAIL_COUNT="${STATS% *}" + SPEC_COUNT="${STATS#* }" - if [ "$FAIL_COUNT" = "0" ]; then + if [ "$FAIL_COUNT" = "0" ] && [ "$SPEC_COUNT" -ge "$MIN_SPECS" ]; then echo "| ${db} | :white_check_mark: Pass |" >> $GITHUB_STEP_SUMMARY + elif [ "$FAIL_COUNT" = "0" ]; then + echo "| ${db} | :warning: ${SPEC_COUNT} tests (zero-test guard) |" >> "$GITHUB_STEP_SUMMARY" elif [ "$FAIL_COUNT" = "-1" ] && [ "$IS_SOFT_FAIL" = true ]; then echo "| ${db} | :warning: Error (soft-fail) |" >> $GITHUB_STEP_SUMMARY elif [ "$FAIL_COUNT" = "-1" ]; then @@ -555,6 +588,40 @@ jobs: name: junit-${{ matrix.cfengine }} path: /tmp/junit-results/ + ############################################# + # RustCFML (experimental, JVM-free engine) + ############################################# + # Informational lane, never a merge gate. The engine is pinned in + # tools/rustcfml/ENGINE_VERSION (upstream ships multiple releases/day, so + # tracking latest would make this lane flake on engine churn). Pass criteria + # is "no NEW failures vs tools/rustcfml/baseline.json" — known residual + # errors (no-JVM limitations, open upstream issues) live in the baseline. + # To bump the pin: update ENGINE_VERSION, run + # bash tools/rustcfml/run-suite.sh --write-baseline + # locally, and commit both files together. + rustcfml: + name: "rustcfml (experimental)" + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout Repository + uses: actions/checkout@v5 + + - name: Read pinned engine version + id: engine + run: echo "version=$(tr -d '[:space:]' < tools/rustcfml/ENGINE_VERSION)" >> $GITHUB_OUTPUT + + - name: Cache engine binary + uses: actions/cache@v4 + with: + path: ~/.cache/wheels-rustcfml + key: rustcfml-${{ steps.engine.outputs.version }}-linux-x86_64 + + - name: Run core suite against pinned RustCFML + env: + GH_TOKEN: ${{ github.token }} + run: bash tools/rustcfml/run-suite.sh + ############################################# # Publish Test Results to PR ############################################# @@ -579,6 +646,12 @@ jobs: files: junit-results/**/*.xml check_name: "Wheels Test Results" comment_title: "Wheels Test Results" + # Keep the aggregate check neutral (#3302): oracle soft-fail debt + # otherwise pins a red "Wheels Test Results" check to whatever SHA + # the matrix was dispatched on, marking innocent PRs UNSTABLE. + # Leg pass/fail gating lives in the tests job (OVERALL_STATUS); + # annotations, PR comments, and artifacts are unaffected by this. + fail_on: nothing report_individual_runs: true report_suite_logs: any json_file: junit-results/test-results.json @@ -613,27 +686,41 @@ jobs: MATRIX_MD="${MATRIX_MD} " MATRIX_MD="${MATRIX_MD} - | Engine | MySQL | PostgreSQL | SQL Server | H2 | CockroachDB | Oracle | SQLite |" + | Engine | MySQL | PostgreSQL | SQL Server | H2 | CockroachDB | Oracle (soft-fail) | SQLite |" MATRIX_MD="${MATRIX_MD} - |--------|:-----:|:----------:|:----------:|:--:|:-----------:|:------:|:------:|" + |--------|:-----:|:----------:|:----------:|:--:|:-----------:|:------------------:|:------:|" + + # Keep in sync with SOFT_FAIL_DBS and MIN_SPECS in the tests job (#3302). + SOFT_FAIL_DBS="oracle" + MIN_SPECS=4000 for engine in lucee6 lucee7 adobe2023 adobe2025 boxlang; do ROW="| **${engine}** |" for db in mysql postgres sqlserver h2 cockroachdb oracle sqlite; do FILE="results/test-results-${engine}/${engine}-${db}-result.txt" + IS_SOFT_FAIL=false + if echo "$SOFT_FAIL_DBS" | grep -qw "$db"; then + IS_SOFT_FAIL=true + fi if [ -f "$FILE" ]; then - FAIL=$(python3 -c " + STATS=$(python3 -c " import json, sys try: d = json.load(open('$FILE')) - print(int(d.get('totalFail', 0) + d.get('totalError', 0))) + print(int(d.get('totalFail', 0) + d.get('totalError', 0)), int(d.get('totalSpecs', 0))) except: - print(-1) - " 2>/dev/null || echo "-1") - if [ "$FAIL" = "0" ]; then + print(-1, -1) + " 2>/dev/null || echo "-1 -1") + FAIL="${STATS% *}" + SPECS="${STATS#* }" + if [ "$FAIL" = "0" ] && [ "$SPECS" -ge "$MIN_SPECS" ]; then ROW="${ROW} :white_check_mark: |" elif [ "$FAIL" = "-1" ]; then ROW="${ROW} :warning: |" + elif [ "$FAIL" = "0" ]; then + ROW="${ROW} :warning: ${SPECS} tests |" + elif [ "$IS_SOFT_FAIL" = true ]; then + ROW="${ROW} :warning: ${FAIL} |" else ROW="${ROW} :x: ${FAIL} |" fi @@ -647,6 +734,9 @@ jobs: MATRIX_MD="${MATRIX_MD} + *Oracle is soft-fail (non-blocking, tracked in #2663) — :warning: cells in that column never gate the run.* + *A ':warning: N tests' cell means the leg reported fewer than ${MIN_SPECS} testcases (suite likely compile-wiped, counted as failed).* + *Results for commit ${GITHUB_SHA:0:7}.*" # Write to step summary diff --git a/.github/workflows/deploy-ci.yml b/.github/workflows/deploy-ci.yml index 54a3b7589f..b3dd982679 100644 --- a/.github/workflows/deploy-ci.yml +++ b/.github/workflows/deploy-ci.yml @@ -67,7 +67,7 @@ jobs: runs-on: ubuntu-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - LUCLI_VERSION: "0.3.7" + LUCLI_VERSION: "0.6.1" WHEELS_CI: "true" PORT: "8080" steps: diff --git a/.github/workflows/distribution-install-smoke.yml b/.github/workflows/distribution-install-smoke.yml index 08d312de8c..af89c8d685 100644 --- a/.github/workflows/distribution-install-smoke.yml +++ b/.github/workflows/distribution-install-smoke.yml @@ -9,15 +9,27 @@ name: Distribution install smoke (brew / scoop / apt / yum) # - the Homebrew formula auto-update dies when LuCLI ships a binary-less tag # (recurring; homebrew-wheels#383/#384) # - the apt stable Packages index gets clobbered to 0 bytes by a bleeding-edge -# publish (#3218 — and the arm64 stable index is empty right now) +# publish (#3218, fixed by apt-wheels#5 — now guarded by the index-integrity +# job below, which also covers the bidirectional case) # - a tap PR never merges / a dispatch token loses scope, leaving a channel # stuck on an old version # -# This workflow installs the CLI the exact documented way on each channel and -# asserts `wheels --version` reports the current GA. It runs daily (propagation -# has settled by then) and on demand. It does NOT run on `release: published` -# on purpose — right after a tag the channels lag, which would be a false red; -# the daily run is the signal. +# This workflow has two layers: +# 1. index-integrity — a fast, container-free probe of the PUBLISHED apt/yum +# dist indexes (Packages / repomd primary). It asserts each channel's index +# is non-empty and that stable names the current GA. This catches the #3218 +# clobber deterministically: the full-install legs only sample once a day, +# so a clobber that lands outside the 14:00 window (the index is populated +# only briefly right after a stable publish) can slip past them — but an +# empty/missing index always fails this probe with a message that names the +# regression. It also asserts the bleeding-edge index stays non-empty, since +# the clobber was bidirectional (a stable publish could wipe BE too). +# 2. the per-channel install legs — install the CLI the exact documented way +# on each channel and assert `wheels --version` reports the current GA. +# +# Runs daily (propagation has settled by then) and on demand. It does NOT run on +# `release: published` on purpose — right after a tag the channels lag, which +# would be a false red; the daily run is the signal. # # Java is NOT set up by hand anywhere: every package declares/bundles it # (brew `depends_on "openjdk@21"`, the .deb `Depends: openjdk-21-jre-headless`, @@ -75,6 +87,135 @@ jobs: echo "version=$VER" >> "$GITHUB_OUTPUT" echo "All channels must serve wheels $VER" + index-integrity: + needs: resolve + name: "Index integrity (apt + yum dists)" + runs-on: ubuntu-latest + timeout-minutes: 10 + # Direct probe of the PUBLISHED dist indexes — no install, no containers. + # Catches the #3218 cross-channel clobber deterministically: a regression + # that empties a Packages / repomd index always fails here with a message + # that names the regression, even outside the brief post-publish window the + # full-install legs happen to sample. Both directions are checked because + # the clobber was bidirectional (a stable publish could wipe bleeding-edge + # and vice versa) — apt-wheels#5 scopes regen per-channel to prevent both. + env: + # Validated semver from `resolve`; passed via env (never interpolated into + # the shell). The only external input the script touches. + EXPECTED: ${{ needs.resolve.outputs.version }} + # NOTE: deliberately NO cache-buster. apt/dnf fetch the plain URLs, which + # hit Cloudflare's edge cache — so this probe must fetch those SAME plain + # URLs to see what clients see. An earlier draft appended `?cb=` to dodge + # the edge cache; that hit R2 origin instead and showed green while real + # `apt install` failed on a stale edge-cached Packages.gz (#3218 follow-up: + # apt-wheels#6 sets `no-store` on metadata so the edge stops caching it). + steps: + - name: Probe apt + yum stable/bleeding-edge indexes + run: | + set -uo pipefail + fail=0 + note() { echo "::error::$*"; fail=1; } + + # Retrying fetch: a transient blip must not red a daily guardian. -f + # fails on HTTP >=400 so --retry-all-errors retries 5xx too. Prints + # body to stdout; non-zero exit (after retries) => caller sees empty. + fetch() { curl -fsSL --retry 4 --retry-delay 3 --retry-all-errors --max-time 60 "$1"; } + + # All grep checks feed from a here-string (grep PAT <<<"$body"), NOT a + # `printf | grep` pipe. `grep -q` exits on first match without draining + # stdin, so a piped printf of a 100KB index gets SIGPIPE (exit 141) and + # `set -o pipefail` then reports the whole pipeline as failed EVEN WHEN + # grep matched — a false red. A here-string has no upstream process to + # kill, so the exit status is grep's alone. + + # sha256 of a file (sha256sum on Linux runners). Takes a path, NOT + # piped data — gzip blobs are binary and `$(...)` would corrupt them + # (command substitution is text-only: strips trailing newlines, can't + # hold NUL bytes). Always fetch binary to a temp file, then hash/gunzip + # the file. + sha256f() { sha256sum "$1" | awk '{print $1}'; } + + # --- apt stable: replicate what `apt-get update` actually verifies. + # Parse the SHA256 the (plain) Release records for binary-/ + # Packages.gz, then fetch the (plain) Packages.gz and compare. This is + # the exact check apt makes — and the exact one that failed in #3218 + # when a stale edge-cached Packages.gz no longer matched a fresh + # Release ("File has unexpected size"). Then gunzip and confirm the + # content actually lists the GA (a clean clobber can be empty-but- + # internally-consistent: hash matches, content empty — caught here). + rel="$(fetch "https://apt.wheels.dev/dists/stable/Release" || true)" + if ! grep -q "^SHA256:" <<<"$rel"; then + note "apt stable Release missing or has no SHA256 section." + else + for arch in amd64 arm64; do + relpath="main/binary-${arch}/Packages.gz" + exp="$(awk -v p="$relpath" '/^SHA256:/{f=1;next} /^[A-Z]/{f=0} f && $3==p {print $1}' <<<"$rel" | head -1)" + tmp="$(mktemp)" + fetch "https://apt.wheels.dev/dists/stable/${relpath}" > "$tmp" 2>/dev/null || true + act="$(sha256f "$tmp")" + content="$(gunzip -c "$tmp" 2>/dev/null || true)" + rm -f "$tmp" + if [ -z "$exp" ]; then + note "apt stable Release does not record a SHA256 for ${relpath}." + elif [ "$exp" != "$act" ]; then + note "apt stable ${arch} Packages.gz hash != Release (stale edge cache or torn publish — the #3218 'unexpected size' failure). expected=${exp} served=${act:-}" + elif ! grep -q "Version: ${EXPECTED}" <<<"$content"; then + note "apt stable ${arch} Packages.gz is hash-consistent but does not list wheels ${EXPECTED} (empty/old index)." + else + echo "OK apt stable ${arch}: Packages.gz matches Release and lists ${EXPECTED}" + fi + done + fi + + # --- apt: bleeding-edge must stay non-empty (bidirectional guard). + # Versions float (snapshot.N), so only assert it has at least one stanza. + be_body="$(fetch "https://apt.wheels.dev/dists/bleeding-edge/main/binary-amd64/Packages.gz" 2>/dev/null | gunzip 2>/dev/null || true)" + if ! grep -q "^Package:" <<<"$be_body"; then + note "apt bleeding-edge amd64 index is EMPTY (a stable publish may be clobbering it)." + else + echo "OK apt bleeding-edge amd64: index non-empty ($(grep -c '^Package:' <<<"$be_body") entries)" + fi + + # --- yum: resolve repomd -> primary.xml.gz (plain urls), assert the GA. + # repomd records the primary's checksum; verify the served primary + # matches it, then that it lists the GA — the dnf analogue of the apt + # check above. + check_yum() { + local ch="$1" assert_ver="$2" + local repomd loc exp gz act content + repomd="$(fetch "https://yum.wheels.dev/${ch}/repodata/repomd.xml" || true)" + loc="$(grep -oE 'repodata/[a-f0-9]+-primary\.xml\.gz' <<<"$repomd" | head -1 || true)" + if [ -z "$loc" ]; then + note "yum ${ch} repomd.xml has no primary metadata (empty/missing repodata)." + return + fi + # The primary's filename IS its sha256 (createrepo_c names it that, + # and it equals the repomd for the compressed file). + exp="$(sed -E 's@.*/([a-f0-9]+)-primary\.xml\.gz@\1@' <<<"$loc")" + tmp="$(mktemp)" + fetch "https://yum.wheels.dev/${ch}/${loc}" > "$tmp" 2>/dev/null || true + act="$(sha256f "$tmp")" + content="$(gunzip -c "$tmp" 2>/dev/null || true)" + rm -f "$tmp" + if [ -n "$exp" ] && [ "$exp" != "$act" ]; then + note "yum ${ch} primary.xml.gz hash != repomd reference (stale edge cache or torn publish). expected=${exp} served=${act:-}" + elif ! grep -q ') can't push the body past the 100-char - # body-max-line-length enforced by commitlint. - commit_msg_file="$(mktemp)" - { - printf '%s\n\n' "chore(web): refresh visual baseline(s) ($SITES)" - printf '%s\n' "Manually triggered baseline refresh via" - printf '%s\n' ".github/workflows/refresh-visual-baselines.yml" - printf '%s\n\n' "on branch $BRANCH." - printf '%s\n' "Run when an intentional content/layout change makes the visual-regression" - printf '%s\n' "check fail. The new PNG(s) under web/tests/visual-baselines/ are now the" - printf '%s\n' "expected rendering; re-run the failing visual-regression job to flip the" - printf '%s\n' "check green." - } > "$commit_msg_file" - - git commit -F "$commit_msg_file" - rm -f "$commit_msg_file" - git push origin "HEAD:$BRANCH" + TARGET_BRANCH: ${{ github.ref_name }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: bash tools/gh-open-refresh-baseline-pr.sh - name: Write step summary if: always() @@ -182,21 +180,32 @@ jobs: SITES: ${{ inputs.sites }} CHANGED: ${{ steps.changes.outputs.changed }} BRANCH: ${{ github.ref_name }} + DELIVERY: ${{ steps.deliver.outputs.delivery }} + PR_URL: ${{ steps.deliver.outputs.pr_url }} run: | { echo "## Refresh visual baselines" echo echo "- **Target branch:** \`$BRANCH\`" echo "- **Sites requested:** \`$SITES\`" - if [ "$CHANGED" = "true" ]; then - echo "- **Result:** Baseline(s) committed and pushed." + if [ "$CHANGED" = "true" ] && [ "$DELIVERY" = "push" ]; then + echo "- **Result:** Refreshed baseline(s) pushed directly to \`$BRANCH\`." + echo + echo "The commit retriggers CI on the branch; \`visual-regression\` should now pass." + elif [ "$CHANGED" = "true" ] && [ "$DELIVERY" = "pr" ]; then + echo "- **Result:** Refresh PR opened against \`$BRANCH\`: $PR_URL" echo - echo "### Next step" + echo "### Next step — a maintainer must merge that PR" echo - echo "The push to \`$BRANCH\` will retrigger CI automatically — the" - echo "\`visual-regression\` check on the next run should pass. If you'd rather" - echo "not wait for a full CI cycle, open the failing run on the PR and click" - echo "**Re-run failed jobs**." + echo "\`$BRANCH\` rejects direct pushes (see #3283), so the refreshed baseline(s)" + echo "travel in a \`chore/refresh-baseline-*\` PR. That PR is authored by the" + echo "workflow's \`GITHUB_TOKEN\`, and GitHub does not fire \`pull_request\`" + echo "workflows for GITHUB_TOKEN-authored PRs — its required checks will NOT" + echo "start on their own, so it cannot land unattended. Either close and reopen" + echo "the PR to trigger CI, or review the PNG diff and merge it directly. Once" + echo "its commit lands on \`$BRANCH\`, \`visual-regression\` should pass." + elif [ "$CHANGED" = "true" ]; then + echo "- **Result:** Baselines changed but delivery did not complete. See the job log." elif [ "$CHANGED" = "false" ]; then echo "- **Result:** No baseline drift detected — nothing to commit." echo diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f6b34036b..c7e195b012 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ on: env: WHEELS_PRERELEASE: false - LUCLI_VERSION: "0.3.7" + LUCLI_VERSION: "0.6.1" # Route JS actions through Node 24 instead of the deprecated Node 20 runtime. # The lone Node 20 action here — Wandalen/wretry.action@v3, which wraps # softprops/action-gh-release — began failing its pre/post hooks with diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 5d5d913222..cfdc15d27f 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -13,7 +13,7 @@ permissions: env: WHEELS_PRERELEASE: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - LUCLI_VERSION: "0.3.7" + LUCLI_VERSION: "0.6.1" jobs: ############################################# diff --git a/.gitignore b/.gitignore index f1c7565382..9f0008e030 100644 --- a/.gitignore +++ b/.gitignore @@ -81,4 +81,16 @@ web/tests/visual-diffs/ # API docs snapshot artifacts — generated fresh from the running Wheels server # on every develop CI run, then used to build the api site. Never committed. /docs/api/v*-snapshot.json -/web/sites/api/src/content/docs/v*-snapshot/ \ No newline at end of file +/web/sites/api/src/content/docs/v*-snapshot/ +# Runtime artifacts written into the working tree by a local test run or dev +# server. All three are regenerated on demand and none are source, but until now +# none were ignored either — so any `git add -A` after running the suite swept +# them into the diff. That happened twice: caught in review on the #3334/#3350/ +# #3349/#3325/#3351 batch, and missed on #3362, which merged seven of them. +# +# public/testbox/system/stubs/ predates this entry with one file already tracked +# (F952D54F…). Ignoring a directory does not untrack what is already in the index, +# so that one stays as-is; only the accidental additions were removed. +public/testbox/system/stubs/ +/rewrite.config +/box.json diff --git a/CHANGELOG.md b/CHANGELOG.md index eddf3522c1..01dd74e368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,104 @@ All historical references to "CFWheels" in this changelog have been preserved fo --- +# [4.0.6](https://github.com/wheels-dev/wheels/releases/tag/v4.0.6) => 2026-08-20 + +### Added + +- `wheels generate auth` — one-command authentication scaffold built on the `wheels.auth` primitives ([#3155](https://github.com/wheels-dev/wheels/issues/3155)). The default session strategy emits a `User` model with PBKDF2 password hashing via the `passwordHasher` service, `Sessions`/`Passwords`/`Registrations` controllers (registration on by default; disable with `--no-registration`), CSRF-safe `startFormTag` views, a create-users migration with a unique email index, marked route/service/strategy blocks injected into `config/routes.cfm`, `config/services.cfm`, and `app/events/onapplicationstart.cfm`, plus generated app specs. `--strategy=token` and `--strategy=jwt` emit an `api/Sessions.cfc` controller (opaque SHA-256-digested bearer tokens, or JWTs signed with `WHEELS_JWT_SECRET` that fail loudly at startup when the secret is missing). Generated code is code you own: every file carries a stamped header, and re-running with `--force` regenerates files and replaces the injected blocks in place without duplicating them. +- Added `wheels.auth.PasswordHasher`, a cross-engine password hashing service using PBKDF2-HMAC-SHA256 (600,000 iterations by default per OWASP 2023+, 16-byte SecureRandom salt, 256-bit derived key) with a self-describing modular-crypt storage format (`$pbkdf2-sha256$i=$$`). `verify()` compares digests in constant time and returns `false` (never throws) on malformed input; `needsRehash()` enables transparent work-factor upgrades. Hashes are byte-identical across Lucee, Adobe CF, and BoxLang, so they survive engine migrations. Groundwork for `wheels generate auth` (#3155, #2962). +- Authorization policy layer: new `wheels.Policy` base class (default-deny — every standard action denies and `scope()` returns a no-rows chain) with `app/policies/Policy.cfc` resolution, plus `authorize()` / `can()` / `policyScope()` controller-and-view helpers. `authorize()` throws `Wheels.NotAuthorized` (HTTP 403, mapped like `Wheels.RecordNotFound` → 404) and returns the record on allow; a missing policy class throws `Wheels.Policy.NotDefined` in development/testing and silently denies in production. The current user resolves through the `currentUser` DI service, then a configured authenticator strategy's `currentUser()`, then guest — customizable by overriding `$currentUserForPolicy()`. Includes a `wheels generate policy ` CLI generator and a new Authorization Policies guide (#3156, part of #2962) +- Added a pluggable storage-disk abstraction under `wheels.storage` with `LocalDisk` and `S3Disk` drivers behind a uniform `put/get/exists/delete/url/signedUrl` interface, resolved by name through `StorageManager`. S3 access — including presigned, expiring URLs — uses a from-scratch SigV4 signer over plain `cfhttp` (no AWS SDK, no JARs) (#3157). +- Added an `includeCalculated` argument to `findAll()`, `findOne()`, and `findByKey()` for additively opting a `select=false` calculated SQL property back into a single finder — e.g. `model("User").findAll(includeCalculated="fullName")`. Unlike `select`, it merges the named calculated properties on top of the default column list rather than replacing it, so the rest of the record is still returned. Unknown names throw `Wheels.CalculatedPropertyNotFound` in development/testing and are ignored in production (#3252) +- Capability-based engine degradation: engine adapters expose `supportsImageInfo()` and a cached plain-data `getCapabilities()` aggregate, and `imageTag()` skips its width/height dimension probe on engines whose adapter reports no image support — rendering the tag without dimensions instead of erroring. New `wheels.wheelstest.EngineCapabilities` JVM probes (`hasJvmClassLoading()`, `canWriteSystemProperties()`) let browser tests skip cleanly with a typed `Wheels.BrowserJvmUnavailable` error on engines without a JVM instead of failing mid-classloader setup. RustCFML engine detection fixed: the `server.coldfusion.productName` marker is now checked before the Lucee branch (RustCFML exposes a Lucee-impersonating `server.lucee` struct), so it resolves to its dedicated adapter again — which now reports `cfcache` support (implemented in RustCFML v0.417) and ships a defensive zero-dimension `imageInfo()` fallback. +- `select()`, `include()`, `group()`, `distinct()`, and `forUpdate()` can now start a query-builder chain directly on the model class (e.g. `model("Person").select("id,firstName").where("department", "engineering").get()`), matching `where()` and the other entry-position builder methods. `forUpdate()` is also available when transitioning from a scope chain. ([#3346](https://github.com/wheels-dev/wheels/issues/3346)) +- In the development environment, a refused `?reload=true` is no longer a silent no-op: the debug bar now renders an inline notice explaining why the reload did not fire and what to do. Three cases are distinguished — `reloadPassword` is empty (URL reload disabled, fail-closed since 4.0.4, with the `set(reloadPassword=env('WHEELS_RELOAD_PASSWORD', ''))` fix inline), the `password` URL parameter is missing, and a deliberately generic "refused" for wrong-password or rate-limited attempts (pointing at `wheels_security.log` without distinguishing the two, so the notice adds no oracle on top of the constant-time compare). The reload gate in all four `public/Application.cfc` template copies records the refusal reason in `request.wheels.reloadRefusedReason` (pinned by a structural parity spec); message text and the development-only gate live framework-side in the debug bar. Other environments are unchanged: silent no-op plus `wheels_security.log`, exactly as before ([#3311](https://github.com/wheels-dev/wheels/issues/3311)) + +### Changed + +- `vendor/wheels/Global.cfc` is no longer a 4,800-line monolith: helpers now live in focused `vendor/wheels/global/*.cfm` includes compiled into the component. `$include` stays on `Global.cfc` and collapses `../../#eventPath#` (Application.cfc `onAbort` / `onApplicationEnd`) to the `/app/events` mapping so `onabort.cfm` and `onapplicationend.cfm` resolve after the split. Component-body `/wheels/global/*.cfm` includes fall back to mapping-free paths when Adobe CF 2023 `applicationStop()` drops `THIS.mappings` (authorized reload was HTTP 500 on `locking.cfm`). `$abortInvalidRequest` measures request depth against `ExpandPath("/wheels/Global.cfc")`. The public `$`-prefixed mixin surface is unchanged (#3241) +- `wheels new` generates a richer default home page: a runtime status line (Wheels version, engine, database, environment) plus a Next-steps command guide, replacing the bare two-line placeholder — surfacing the onboarding content from the redesigned framework welcome page where users actually land (#2098) + +### Performance + +- Model, controller, and mapper object creation no longer re-scans the framework mixin folders (a directory listing plus a `createObject` and `getMetaData` per file) on every materialization. The mixin-integration plan is now built once per application and reused, and the per-method `$willBeOverriddenByMixin` lookup is precomputed — cutting model-instance creation roughly in half (every `new()` and every finder row was paying the full cost). This is the regression behind slow test-suite and request times reported on 4.0.x (#3213) +- `wheels.channel.DatabaseAdapter.cleanup()` now pushes the `maxRows` bound into dialect SQL (`SELECT TOP n` on SQL Server, `FETCH FIRST n ROWS ONLY` on Oracle, `LIMIT n` everywhere else) via the new `$applyRowBound()` helper, so bounded retention passes do an index-assisted top-n read instead of materializing the whole expired backlog and truncating it client-side. The driver-level `maxrows` option is kept as belt-and-braces. + +### Fixed + +- The application template's `this.wheels.rootPath` now anchors to `GetCurrentTemplatePath()` (the `public/` front-controller directory) instead of `GetBaseTemplatePath()` (whatever file was originally requested). When a request bootstrapped under a subfolder — e.g. the test runner — the old base-template anchor produced an unstable path, and because `rootPath` seeds `this.name` via `Hash(rootPath)`, that silently split one app across two application scopes (the "reload=true fixes it" symptom). The value is identical for a normal front-controller request, so existing apps are unaffected (#3025, refs #2887) +- Custom validation `condition`/`unless` expressions that call a model method with a **positional** argument — e.g. `condition="this.propertyIsPresent('productid')"` — now evaluate correctly instead of throwing `The parameter [property] ... is required but was not passed in`. The condition argument parser previously understood only named arguments (`key='val'`) and silently dropped positional ones; it now maps positional arguments onto the target method's declared parameter names (#3238) +- Nested `include` strings whose parenthesized intermediate is a `belongsTo` (e.g. `findAll(include="SecondaryContact(User)")`) again generate flat sibling joins, keeping the root `FROM` table in scope for every `ON` condition. The issue #449 HABTM/`through` parenthesized-grouping heuristic was over-firing on plain `belongsTo`-chain includes, producing a nested join expression that MySQL rejected with `Unknown column '.' in 'on clause'` — a regression from Wheels 2. The grouping now consults the association metadata and only nests for a genuine `hasMany`/`hasOne` bridge, so HABTM/`through` includes still nest as before (#3245) +- `/wheels/app/tests` now renders the TestBox-style HTML report in a browser for apps that use the built-in fallback test runner, matching `/wheels/core/tests`. The endpoint previously returned raw JSON for the no-format default and `?format=html`; `?format=json`, `?format=txt`, and `?format=junit` are unchanged, and an unrecognized `?format=` value still returns no body as before (#3251). +- Fixed an Adobe ColdFusion `Routines cannot be declared more than once` HTTP 500 in the shared test-report template (`vendor/wheels/tests/html.cfm`) that broke the `format=html` report for both the app and core test runners on repeated requests. The recursive helper is now declared as a variables-scoped function expression, matching the core runner's existing convention (#3251). +- The scaffolded `tests/runner.cfm` now resolves its include of the built-in app test runner through `$resolveSubpathInclude()` instead of a hardcoded absolute `/wheels/tests/app-runner.cfm` path. Under a URL subpath / CommandBox multi-subfolder install the bare `/wheels` mapping did not resolve, so `/wheels/app/tests` and `wheels test` failed; the include is now prefixed with the app's resolved `webPath` and works at the web root and under a subfolder alike (#3251, refs #2887) +- Fixed the wheels.dev header and footer logo being invisible in dark mode — the `lockup` logo variant now swaps to the white lockup under `prefers-color-scheme: dark` via a pure-CSS toggle ([#3264](https://github.com/wheels-dev/wheels/issues/3264)) +- The **Refresh visual baselines** workflow (`.github/workflows/refresh-visual-baselines.yml`) no longer hard-fails when the dispatched branch rejects direct pushes (`GH013: Changes must be made through a pull request`, e.g. `develop`). Delivery now lives in `tools/gh-open-refresh-baseline-pr.sh`: branches that allow it still get the direct push (unchanged feature-branch flow), and protected branches get a `chore/refresh-baseline-*` PR instead — left for a maintainer to merge, since GITHUB_TOKEN-authored PRs never trigger the required checks. The job now also grants `pull-requests: write` (#3283) +- Migrator column helpers no longer lose their declared `default` values on Adobe ColdFusion. A parameter declared as ` default` (e.g. `string default = "newid()"`) is parsed by Adobe as a parameter named `string`, silently discarding the `default` name and its declared value — so `t.uniqueidentifier()` emitted DDL with no `DEFAULT` clause and `t.float()` lost its `default=""` / `allowNull=true` outlier defaults. The type keyword has been dropped from every `default` parameter declaration in `Migration.cfc`, `TableDefinition.cfc`, `Abstract.cfc`, the MySQL/SQLite migrators and `DatabaseMigratorAdapterInterface.cfc` (#3302) +- `$evaluateExpression()` now evaluates built-in-function expressions through the BoxLang runtime on BoxLang. BoxLang ships no `Evaluate()` BIF, so every expression that fell through to the built-in branch returned `Error evaluating expression: Function [Evaluate] not found` instead of its result (#3302) +- `Channel` database adapter `cleanup(maxRows=...)` no longer sets the driver-level `maxrows` query option when the row bound has already been pushed into dialect SQL. On BoxLang the option reaches the PostgreSQL driver as `setLargeMaxRows()`, which pgjdbc does not implement, so the bounded retention pass threw and reported zero rows deleted on PostgreSQL and CockroachDB — leaving expired `wheels_events` rows to accumulate (#3302) +- `CockroachDBTransactionSpec` now declares an isolation level on the outer transaction that wraps `updateAll(transaction="rollback")`. Adobe ColdFusion rejects a nested `cftransaction` whose isolation level differs from its parent's, and the resulting exception escaped `invokeWithTransaction` before its `catch` could clear `request.wheels.transactions`, leaving the connection permanently marked as "transaction already open" — so every later model call in that request silently skipped its own transaction and `OuterTransactionSignalSpec`'s rollback assertion failed as a knock-on (#3302) +- `$parseInsertColumnList()` now uses one implementation on every engine instead of forking on a BoxLang check whose non-BoxLang branch dropped the comma delimiters when it ran on BoxLang. The unified regex form also preserves spaces inside quoted identifiers such as `[order date]`, which the previous `ReplaceList` form stripped (#3302) +- `LocalDisk.put()` now writes content as bytes rather than as a string, so `get()` round-trips exactly what was stored. Adobe ColdFusion 2025's `FileWrite()` appends a trailing line feed to simple values, which added a byte to every stored object and corrupted binary payloads (#3302) +- Helper functions included into `wheels.Public` by `$init()` are now reachable on the component's `this` scope on every engine. The runtime include placed them in `variables` only, so external callers hit "has no function with name" on Lucee 6, Adobe 2023 and Adobe 2025 while the same call worked on Lucee 7 and BoxLang (#3302) +- `invokeWithTransaction()` now clears `request.wheels.transactions` when the `cftransaction` fails to open, not only when the wrapped method throws. A rejected isolation level, a nested-isolation mismatch, or a dead connection previously left the connection marked "transaction already open" for the rest of the request, so every later model call silently ran with no transaction at all (#3302) +- Overriding a controller or view helper now registers the framework original as `super`, matching the model layer. Following the "Overriding Core Methods" guide — override `linkTo()`, call `superLinkTo()` — produced a 500, because `Controller.cfc`'s `$integrateFunctions()` only aliased `super` for names a registered plugin/package mixin overrode, while `Model.cfc`'s aliased it for any name already present on the target. App-level overrides of controller and view helpers silently got nothing. The manual `variables.coreLinkTo = CreateObject("component", "wheels.view.links").linkTo` workaround is no longer needed. Controllers that override nothing gain no extra keys — no two framework mixins contribute the same name, so the branch only fires on a genuine override (#3325, from discussion #3323) +- `findAll(include="...")` no longer copies a nested association's `INNER JOIN` into unrelated sibling joins. When a nested group followed one or more shallow associations (e.g. `include="comments,classifications(tag)"`), the issue #449 parenthesized grouping spliced the nested `INNER JOIN` into every preceding `LEFT OUTER JOIN`, so those joins referenced a table the query had not introduced yet — Oracle rejected it with `ORA-00904: invalid identifier`, MySQL with `Unknown column '
.' in 'on clause'`. Each `INNER JOIN` is now scoped to the single association it is nested under, taken from the include's association tree rather than re-derived from the generated SQL text. Reported with a working patch by Mike Grogan (#3334) +- **Behaviour change:** `include` order no longer changes the SQL a query generates. Grouping used to be gated on an anchored pattern over the include string that only matched when the nested group came last, so `include="a(b),c"` and `include="c,a(b)"` produced structurally different joins for the same query. In the nested-first form the nested `INNER JOIN` was emitted at the root, which demoted the sibling `LEFT OUTER JOIN` to an inner join and silently dropped parent rows that had no associated record. Both orderings now emit the same joins, so a query written in the nested-first form can return **more** rows than before — the rows a `hasMany`/`hasOne` include is meant to preserve. Pass `joinType="inner"` on the association if the filtering was intentional (#3334) +- The per-request finder cache is now namespaced under `request.wheels.$queryCache[ModelName]` instead of sitting directly in `request.wheels[ModelName]`. Because CFML struct keys are case-insensitive, the flat layout let a model name alias onto a framework-owned request key: an app with a model named `Tenant` — the documented name for the control-plane model in a database-per-tenant app — shared one key between its query cache and `request.wheels.tenant`. Two silent failures followed. `$clearRequestCache()`, which runs after every create/update/delete/bulk operation, wiped the resolved tenant to `{}`, so every tenant-scoped query later in that request fell back to the control-plane datasource and wrote to the wrong database with no error. And a `Tenant` finder running before `TenantResolver` (the obvious shape for a subdomain→tenant directory) populated `request.wheels.tenant` with query-cache entries, making an unresolved request look resolved to any `IsDefined("request.wheels.tenant")` guard. Caching behaviour is otherwise unchanged (#3336) +- `tenant()` now treats a value on `request.wheels.tenant` as an active tenant only when it carries a non-empty `dataSource` — the same test `$tenantDataSource()` already applies before routing a query — and returns an empty struct otherwise. Previously it handed back whatever occupied the key, so a malformed value read as a resolved tenant to any `IsDefined("request.wheels.tenant")` or truthiness guard. Relatedly, `wheels.middleware.TenantResolver` now deletes any pre-existing value on the key when its resolver returns no match, instead of leaving a stale or foreign one to outlive resolution for the remainder of the request. Together these downgrade a malformed tenant context from wrong behaviour to a no-op. Every framework producer (`switchTenant()`, `TenantResolver`, `Job.$restoreTenantContext()`, `TenantMigrator`) already guarantees a non-empty `dataSource`, so correctly-resolved tenants are unaffected (#3336) +- Association foreign-key defaults now resolve either reference-column convention instead of only the legacy `` one. `useUnderscoreReferenceColumns` (framework default `false`, `wheels new` template default `true`) makes the migrator emit `user_id`, but the model layer derived `userid` unconditionally — so a stock new app that declared `belongsTo("user")` without an explicit `foreignKey` threw `key [userid] doesn't exist` the first time any `include=` traversed the association. The default is now resolved against the columns that actually exist on whichever side owns the foreign key (`belongsTo` looks at the declaring model, `hasMany`/`hasOne` at the associated one), so both conventions work — including apps that enabled the flag mid-life and hold a mix of both shapes. This is deliberately schema-driven rather than reading the setting: `references()` re-reads the flag on every call while the model-side default is memoized for the application lifetime, so a flag-driven default would let a runtime flip change migrations without changing models. It is also strictly error-reducing — the underscore form is only consulted when the legacy form is absent, which is a case that used to throw. Polymorphic associations are not covered; they pin their foreign key at registration time, before the schema is available, and still need an explicit `foreignKey=` under the underscore convention (#3337) +- An association whose *derived* default foreign key matches no column on the model that owns it now throws `Wheels.AssociationForeignKeyNotFound` at association-resolution time, naming the association, both candidate column shapes, and `foreignKey=` as the fix. Previously this surfaced as `key [userid] doesn't exist` from deep inside the join builder, which named neither the association nor the argument that resolves it. Development and testing only, and only for defaults Wheels derived itself — an explicit `foreignKey=` is left alone (#3337) +- Pagination handles are now stored under `request.wheels.$pagination[handle]` instead of directly in `request.wheels[handle]`. Handles are caller-supplied names, so the flat layout put arbitrary user input in the same case-insensitive keyspace as framework-owned request state, and the collision ran both ways. Writing: `setPagination(handle="tenant")` replaced the resolved tenant context with a pagination struct, and `handle="$queryCache"` did the same to the per-request finder cache — silently, since neither is validated. Reading: `pagination()` only checks that a handle exists when `showErrorInformation` is on, so in production an unknown handle that happened to name a framework key returned that key's struct as though it were pagination data. `request.wheels` currently holds around thirty-five framework-owned keys — including `params`, `execution`, `currentRoute`, `transactions`, `flashKeep` and `exception` — every one of which was reachable this way. Handles now resolve only inside their own sub-struct, so neither direction can cross over. `Wheels.QueryHandleNotFound` behaviour is unchanged (#3339) +- The PostgreSQL adapter no longer picks up columns from other schemas when introspecting a table. `cfdbinfo(type="columns")` applies no schema restriction, so JDBC matched the table name across every schema on the connection — and PostgreSQL, YugabyteDB and CockroachDB all ship catalog views named `sequences`, `tables`, `columns`, `views`, `triggers` and more. An application table sharing one of those names silently collected a second batch of phantom columns typed `"information_schema"."sql_identifier"`, which no `$getType()` case matched, and the model failed to initialise with the opaque `key [RV] doesn't exist`. Rows from `information_schema`, `pg_catalog`, `crdb_internal` and `pg_extension` are now dropped — inside the `cacheDatabaseSchema` memo, so the filtering costs one pass per datasource+table per application lifetime. Reported against YugabyteDB (PostgreSQL 15 wire protocol) and reproducible on stock PostgreSQL (#3349) +- The migrator's column lookup got the same guard. `vendor/wheels/migrator/Base.cfc` calls `$dbinfo(type="columns")` directly rather than through the model adapter, so `changeTable(name="sequences")` adding a column named `data_type` or `start_value` could see the catalog view's column and treat it as already present (#3349) +- An unmapped PostgreSQL column type now throws `Wheels.UnknownColumnType` naming the type, instead of `key [RV] doesn't exist` from an unassigned return variable — the failure names the column type and points at catalog bleed as the likely cause rather than reading like a framework bug (#3349) +- `validatesUniquenessOf(property="x", scope="y")` now returns a validation result instead of throwing `Component [Model] has no accessible Member with name [y]` when the scope property was never assigned. Building the uniqueness `WHERE` clause dereferenced every scope property without an existence guard, and a scope property is easy to leave absent rather than empty: `$setDefaultValues()` only seeds properties with an explicit `property()` mapping, so a column with a database-level default but no mapping is missing from a `new()`-ed object entirely. An absent scope property is now treated as blank, matching what a present-but-empty one has always done — including the existing conversion of an empty numeric scope to `IS NULL`. The `property(name="", defaultValue="")` workaround is no longer needed (#3350) +- A background job whose `jobClass` cannot be resolved now throws `Wheels.JobClassNotFound` naming the class, the queue row, and the likely causes, instead of the engine's bare `component not found`. `wheels_jobs.jobClass` is written from `GetMetadata(this).name` on enqueue and resolved as a component path on drain, so the failure appears as "component not found" for a class that plainly exists on disk — which sends people to look at mappings and deployment rather than at the persisted string. Component paths are case-sensitive on Linux but not on macOS or Windows, so a casing mismatch resolves in development and fails on a production redeploy, long after the row was written. A path that resolves to something without a `perform()` method now throws `Wheels.InvalidJobClass` rather than failing later inside job execution. Both processing paths (`Job.$processJob` and `JobWorker.$executeJob`) share the check (#3351) +- Verified across every engine: the `jobClass` string persisted on enqueue always round-trips. Lucee and BoxLang derive the metadata name from the file, so a miscased path still yields the canonical name; Adobe's component resolver is case-sensitive independently of the filesystem, so a miscased path does not construct at all. Either way a caller's miscasing cannot reach `wheels_jobs.jobClass`. Pinned by `JobClassRoundTripSpec`, which runs on all five engines rather than assuming the invariant (#3351) +- `wheels test` no longer dies with `Read timed out` on a suite that takes more than about two minutes. The CLI's HTTP client applied a hardcoded 120-second read timeout to every request, which is right for the short request/response bridge commands but is a hard ceiling on how big a suite the test command can run — and it produced **no result at all**, not a failure report, so a passing suite was indistinguishable from a hung app. The budget is now 900 seconds by default and configurable with `--timeout=` or `WHEELS_TEST_TIMEOUT`. When it is still exceeded, the message says which side gave up, that the specs may well have passed, and how to give it longer or scope the run. The browser-test runner, which makes the same long-running call, got the same budget (#3352) +- `tools/test-local.sh` writes its results to a per-checkout file instead of a single fixed `/tmp` path, so two working copies running the suite no longer overwrite each other — which silently turned a develop-vs-branch comparison into two copies of the same run. It also clears the file before the request, so a run that fails outright (`HTTP 000`, typically a server that is not up yet) can no longer leave the previous run's results behind to be read as if they were current. Override with `WHEELS_TEST_RESULT_FILE` (#3352) +- A CSRF cookie written under the legacy bare `AES` (ECB) default is now always read via the legacy fallback, instead of roughly 1 time in 256 being reported as corrupted. `$decryptCsrfCookieValue()` tried the configured algorithm and fell back to bare `AES` only from its `catch` block — treating "did not throw" as "decrypted correctly". Decrypting an ECB ciphertext under `AES/CBC/PKCS5Padding` throws only when the trailing plaintext bytes fail padding validation, and they pass by chance about 1 time in 256, so `Decrypt()` returned garbage and the fallback never ran. The decrypt result is now validated before it is accepted: this cookie's plaintext is always the JSON written by `$generateCookieAuthenticityToken()`, so a non-JSON result means the wrong algorithm was used and the legacy attempt still runs. `AES/GCM/NoPadding` is authenticated and always threw, so only the engines that fall back to CBC were affected. Fails closed either way — a genuinely corrupt cookie is still reported exactly as before (#3361) +- Docs, `--help`, and the packages website now agree that the install verb is `wheels packages add` — `wheels packages install` is intercepted by LuCLI before the Wheels module runs and does not install anything. The Basecoat bonus chapter also says to copy the showcase from `vendor/` after `add`, not from the raw GitHub tree (#3378) +- The application template's `onApplicationEnd()` handler now invokes the Wheels global through the passed-in `arguments.applicationScope.wo` (guarded with `StructKeyExists`) instead of the live `application.wo` scope. On Adobe ColdFusion 2023 the `application` scope is unreliable during `applicationStop()` teardown, so bare `application.wo` could resolve to a stale Java `String[]` and throw `Element wo is undefined in a Java object of type class [Ljava.lang.String;`, erroring the whole site until a CF service restart. The same fix is applied to the repo's demo app and the bundled example apps; existing apps should apply the same edit to their `public/Application.cfc` (#3379) +- Debug bar reload link (and the CFML error page's displayed URL) now honors the `subpath` + setting: the base URL is composed from the resolved `webPath` plus the front-controller + filename — the same idiom as `urlFor()` — instead of raw `cgi.script_name`, so subfolder + deployments emit `/myapp/posts?reload=` instead of the unroutable + `/myapp/public/index.cfm/posts?reload=`. Root installs render byte-identical to before. + Extracted into the unit-tested `$buildDebugReloadUrl()` helper in `Global.cfc` ([#3344](https://github.com/wheels-dev/wheels/issues/3344)) +- Debug bar: the minimized "Debug" restore button now renders after clicking the X. The `#wdb-minimized` button was nested inside the `#wheels-debugbar` container that `wdbMinimize()` hides with `display:none`, so it could never appear and the bar stayed gone for the whole browser session (manual `sessionStorage` cleanup was the only recovery). It is now a sibling of the container, so minimizing shows the restore button bottom-right and clicking it brings the bar back ([#3345](https://github.com/wheels-dev/wheels/issues/3345)). +- Repointed 16 dead `v4-0-0-snapshot`- and `3.1.0`-era guide URLs at live `guides.wheels.dev/v4-0-0/` pages: the scaffolded app's `config/settings.cfm`/`routes.cfm`/`environment.cfm` comments, three template READMEs (mailers/jobs/plugins), both `ConfigRoutes.txt` templates, two runtime CLI messages (`Module.cfc` install pointer, `Doctor.cfc` remediation), `cli/README.md`, the analyze report footer, and the demo app's config. `ConfigRoutesStaleDocUrlSpec` now structurally guards the template tree and known runtime-message files against reintroducing retired URL shapes. +- The test framework (`wheels.wheelstest`) no longer crashes when an exception object lacks the optional cfcatch members `stackTrace` / `extendedInfo` — e.g. custom-thrown, deserialized, or engine-variant exceptions. Spec-result recording (`BaseSpec` fail/error catch blocks), `Assertion.throws()`, the bundle-runner rethrow and status-header logging in `TestBox.cfc`, and the JUnit reporter's failure/error/global-exception sections now default missing members to an empty string instead of nuking the whole bundle's results. A structural spec (`ReporterCfcatchGuardSpec`) pins every optional-member read under `wheelstest/system` to the null-safe idiom +- Engine detection now recognizes RustCFML v0.507+ running in its default `reportAsLucee` mode (where `server.coldfusion.productName` reports "Lucee") by matching the stable `server.lucee.versionName == "RustCFML"` identity marker, so the dedicated RustCFML engine adapter is selected instead of the Lucee adapter and adapter-gated behavior (image-info support, capability probes, engine version gates) routes correctly again. +- Web test runner isolation: `/wheels/core/tests` and `/wheels/app/tests` (and + TestClient / browser requests that send `X-Wheels-Test-Context` or the + `WHEELS_TEST_CONTEXT` cookie) now bind a separate CFML application name + (`_wheelsTest`) when `Application.cfc` includes + `vendor/wheels/events/testcontext.cfm` after `config/app.cfm`. The live + `application.wheels` is no longer swapped for the duration of a run, so + concurrent normal requests keep production config. The snippet ships in + `wheels new` and the demo app; existing apps keep the [#3373](https://github.com/wheels-dev/wheels/pull/3373) + named-lock swap on the live scope until they add the include + (refs [#3374](https://github.com/wheels-dev/wheels/issues/3374)). +- Web test runner (`/wheels/core/tests` and `/wheels/app/tests`): the swap→run→restore window that + temporarily replaces the live `application.wheels` config with test configuration is now serialized + under an exclusive named lock, and the restore runs in a `finally` block. Overlapping test requests + can no longer clobber each other's `application.$$$wheels` backup and leave test config live until + the next `reload=true`, and an erroring suite now restores the original config too. ParallelRunner + partition sub-requests detect the already-applied swap and skip both the swap and the shared lock, + so parallel test mode does not deadlock. Note: this serializes test-vs-test only — a normal request + concurrent with a test run still sees swapped config; true isolation is deferred to a + separate-application-context design (refs [#3025](https://github.com/wheels-dev/wheels/issues/3025)). + Also removes the orphaned legacy RocketUnit runner twin `vendor/wheels/rocketunit_tests/Test.cfc` + (nothing loads it; the active legacy chain via `wheels.Test` is unchanged). + +### Security + +- The `/wheels` welcome page now defense-in-depth gates itself with `$blockInProduction()` like every other `Public` handler, so it no longer renders outside `development` when `enablePublicComponent` is manually enabled — closing a version/engine/database/environment disclosure gap (reverses the #2233 exception) + +--- + # [4.0.5](https://github.com/wheels-dev/wheels/releases/tag/v4.0.5) => 2026-06-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 77d33bcb8d..6eb19f355c 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,9 +47,93 @@ The framework must run on Lucee 5/6/7, Adobe CF 2018/2021/2023/2025, and BoxLang 8. **`Left(str, 0)` crashes Lucee 7.** Guard: `len > 0 ? Left(str, len) : ""`. 9. **`toBeInstanceOf("component")` fails on BoxLang** — returns the FQN, not the literal `"component"`. Use `toBeWheelsModel()` for finder results. 10. **Adobe CF 2023 and 2025 reject the `arguments` scope as `attributeCollection` on *any* built-in CFML tag.** Affects every `cfheader` / `cfcache` / `cfcontent` / `cfmail` / `cfdirectory` / `cffile` / `cflocation` / `cfhtmlhead` / `cfimage` / `cfdbinfo` / `cfinvoke` / `cfwddx` / `cfzip` wrapper. Covers both the string-interpolated (`attributeCollection = "#arguments#"`) and direct-struct (`attributeCollection = arguments`) forms. Adobe 2023/2025 throw — `cfheader`'s message is `"Failed to add HTML header"`; other tags surface their own — and `$header()` is catastrophic because it runs on every request. Copy to a plain struct first: `local.args = {}; for (local.key in arguments) { local.args[local.key] = arguments[local.key]; }`. Lucee 6/7, BoxLang, and Adobe 2018/2021 accept both forms; Adobe 2023/2025 require the plain struct. The 13 sites in `vendor/wheels/Global.cfc` were patched uniformly in [#2750](https://github.com/wheels-dev/wheels/pull/2750). -11. **`local.X = ...` inside `catch` doesn't persist on BoxLang.** Catch body runs under a nested `local` that gets discarded on exit, so `expect(local.X)` after the catch reads the un-touched outer value. Use a struct field: `var state = {flag = false}; ... state.flag = true;`. Bare `var bareName` + unscoped `bareName = true` also works but the struct form mirrors `TenantResolverSpec` and is the prior-art pattern. +11. **Anything written through `local.` inside `catch` doesn't persist on BoxLang.** Catch body runs under a nested `local` that gets discarded on exit, so `expect(local.X)` after the catch reads the un-touched outer value. Use a struct field: `var state = {flag = false}; ... state.flag = true;`. Bare `var bareName` + unscoped `bareName = true` also works but the struct form mirrors `TenantResolverSpec` and is the prior-art pattern. + + **The struct form only works if you access it WITHOUT the `local.` prefix.** `local.state.flag = true` inside a catch fails exactly like a scalar `local.X = ...` — the nested `local` shadows `local.state`, so the write lands on a discarded copy rather than mutating the outer struct. The prefix is what breaks it, not the assignment shape: + ```cfm + var state = {type = ""}; // RIGHT + try { ... } catch (any e) { state.type = e.type; } + local.state = {type = ""}; // WRONG — silently empty after the catch + try { ... } catch (any e) { local.state.type = e.type; } + ``` + This matters because `local.`-scoping spec variables is the house style everywhere else, so "tidying" a catch-using spec to match is an easy and invisible way to break it. Doing exactly that to `JobClassRoundTripSpec` cost two BoxLang failures on every database (`Expected [Wheels.JobClassNotFound] but received []`) — green on Lucee, caught only by the compat matrix. 12. **`for (local.i = ...)` inside `finally` miscompiles on Lucee 7.** Lucee 7.0.1+100 throws `variable [local] doesn't exist` at runtime when a `for` loop declares or iterates `local`-/`var`-scoped variables inside a `finally` block (one probe shape even produced a JVM `Expecting a stackmap frame` verifier error). Bare assignments and function calls in `finally` are fine; loops are not. Hoist the loop into a `public` `$`-prefixed helper and call it from `finally` — reference: `$restoreEmailViewVariables()` in `vendor/wheels/controller/miscellaneous.cfc` ([#2922](https://github.com/wheels-dev/wheels/pull/2922)). 13. **Bare tag-in-script statements without parentheses (e.g. `cfabort;`) are Lucee-only.** Adobe CF compiles the bare token as a reference to an undefined VARIABLE and throws `Variable CFABORT is undefined` at runtime (every Adobe engine, not just one release). Use the script keyword (`abort;`) or the parenthesized call form (`cfheader(...)`-style) instead. The `enablePublicComponent=false` 404 branch in `vendor/wheels/Dispatch.cfc` shipped a bare `cfabort;`, which turned `GET /` on every stock Adobe install in `testing`/`production` into an HTTP 500 ([#3029](https://github.com/wheels-dev/wheels/issues/3029)). Structural guard: `vendor/wheels/tests/specs/security/BareCfabortGuardSpec.cfc` fails the suite if any bare script-context `cfabort` statement reappears under `vendor/wheels/**/*.cfc` (tag-context `` in `.cfm`/tag-based CFCs stays legal). +14. **Adobe 2025's JVM rejects member calls on JDK-internal classes (JPMS).** Calling any member on an object whose runtime class lives in an unexported package (`com.sun.*`, `jdk.internal.*`) — e.g. the `com.sun.crypto.provider.PBKDF2KeyImpl` returned by `SecretKeyFactory.generateSecret()` — throws `java.lang.reflect.InaccessibleObjectException` on Adobe 2025 (its reflection layer bulk-`setAccessible`s the concrete class's methods; Lucee, BoxLang, and Adobe ≤2023 tolerate the same call, so **local Adobe 2023 green does NOT cover this**). Route the call through the exported interface's `Method` object instead: `CreateObject("java","java.lang.Class").forName("javax.crypto.SecretKey").getMethod("getEncoded", JavaCast("null","")).invoke(keyObj, JavaCast("null",""))` — `getMethod`/`invoke` treat the null varargs as empty. Hit by `PasswordHasher.$deriveKey()` ([#3300](https://github.com/wheels-dev/wheels/issues/3300)); watch for it with any Java factory API that returns internal implementation types. + +15. **A parameter named `request` makes the bare `request` token resolve inconsistently on Adobe 2025.** In a function declaring a parameter named `request`, Adobe CF 2025 can resolve bare `request` to the built-in scope in one expression position and to `arguments.request` in another *within the same function* — so a guard written one way cannot protect an access written the other way. `if (StructKeyExists(request, "wheels")) { StructDelete(request.wheels, "tenant"); }` passed the guard and then threw `Element WHEELS is undefined in REQUEST`. Use `IsDefined("request.wheels.tenant")`, which string-resolves the whole dotted path in one evaluation, or assign before use (`if (!StructKeyExists(request, "wheels")) { request.wheels = {}; }` then write) — never mix the two forms. This hits **every middleware component**, because `wheels.middleware.MiddlewareInterface` mandates the signature `handle(required struct request, required any next)`; anti-pattern 11's "never name a parameter after a reserved scope" is unavailable there. Lucee 6/7, BoxLang and Adobe 2023 all resolve consistently, so **local Lucee green and Adobe 2023 smokes do NOT cover this** — only the Adobe 2025 matrix legs catch it, and `compat-matrix.yml` does not run on PRs (weekly cron + `workflow_dispatch`, `continue-on-error: true`). Hit by `TenantResolver.handle()` in [#3338](https://github.com/wheels-dev/wheels/pull/3338). + +16. **Two receiver shapes break Adobe's parser at COMPILE time with the same `MissingNameException`.** Both throw `coldfusion.compiler.CFMLParserBase$MissingNameException: Invalid construct: Either argument or name is missing` ("When using named parameters to a function, each parameter must have a name"). Adobe appears to parse the construct as a script-style tag call and demand at least one attribute. + + **16a — a parenthesized `new` in receiver position, on EVERY Adobe engine.** `(new wheels.Job()).$someMethod(arg = "x")` fails to compile on Adobe **2023 and 2025**; Lucee 6/7 and BoxLang accept it. The argument list is irrelevant here — named arguments do not save it, because the receiver is what the parser chokes on. Hoist the instance to a variable first: + ```cfm + // WRONG — zeroes out both Adobe legs + revived = (new wheels.Job()).$instantiateJobClass(jobClass = persisted); + // RIGHT — variable receiver; 22 spec files already do this and pass on Adobe + var bridge = new wheels.Job(); + revived = bridge.$instantiateJobClass(jobClass = persisted); + ``` + Note the `(new X()).method()` form appears in this file's own Background Jobs examples and in user-facing docs — it is fine in **application** code that only ever runs on Lucee, and fatal in the **core spec suite**, which compiles on all five engines. Hit by `JobClassRoundTripSpec` in [#3351](https://github.com/wheels-dev/wheels/issues/3351). + + **16b — a zero-argument call through the `application` scope, Adobe 2025.** Inside a closure, `application.wo.$someMethod()` with an **empty** argument list — used as a bare statement or as the whole right-hand side of an assignment — fails the same way. This is the `application`-scope sibling of invariant 2. Verified boundaries — each of these compiles, so **do not "fix" them**: + - any argument at all: `application.wo.$get("showErrorInformation")` + - nested inside another call: `expect(application.wo.$statusCode()).toBe(418)` (long-standing in `renderingSpec`) + - chained further: `application.wo.mapper().resources("posts")` (`RoutePrecedenceSpec`) + - a non-`application` receiver, zero args, bare statement in a closure: `_controller.$clearCachableActions()` (`cachingSpec`), `strategy.logout()` (`SessionStrategySpec`), `local.c.$warnIfConfigSkipsSuper()` (`configSuperWarningSpec`) + + Two things make this expensive to diagnose. Adobe attributes the error to the **enclosing `describe(...)` line**, not the offending statement, so it reads like a broken test-block signature. And because the core suite compiles via `directory="wheels.tests.specs"`, one occurrence zeroes out **the entire engine leg** — adobe2025 reports `tests="0"` for every database while Lucee/BoxLang/Adobe 2023 stay green, and `compat-matrix.yml` does not run on PRs. In test code, ensure request state inline (`if (!StructKeyExists(request.wheels, "$pagination")) { request.wheels["$pagination"] = {} }`) rather than calling a void `$`-helper through `application.wo`; in framework code prefer helpers that **return** what they ensure, so callers write `local.store = $ensurePaginationStore();`. Hit by the #3339 pagination-namespace specs. + + Bisect this class of bug with a single probe against a running container instead of CI (~13s vs ~19min): + ```bash + curl -s "http://localhost:62025/wheels/core/tests?db=sqlite&format=json&cli=true" | \ + python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('totalPass','COMPILE FAIL'), d.get('RootCause',{}).get('snippet',''))" + ``` + +17. **A parameter named `default` loses its name — and its declared default value — if a type keyword precedes it, on every Adobe engine.** Adobe treats `default` as reserved in a parameter position, so `string default = ""` registers an argument named **`string`** and discards `default` entirely; the declared default value never materializes in the `arguments` scope. Dropping the type declaration fixes it — `default = ""` (untyped) parses correctly on Adobe, Lucee 6/7 and BoxLang alike. + + ```cfm + // WRONG — arguments scope gets a key named STRING; `default` never appears + public any function float(string columnNames, string default = "", boolean allowNull = "true") { + // RIGHT — arguments.default exists and carries "" + public any function float(string columnNames, default = "", boolean allowNull = "true") { + ``` + + Explicitly-passed values still arrive (as a separate lowercase `default` key alongside the bogus `STRING` one), which is what makes this so quiet: every call site that passes `default=` works, and only the *declared* default silently vanishes. `TableDefinition.uniqueidentifier()` shipped `string default = "newid()"` and emitted DDL with no `DEFAULT` clause on Adobe for as long as it has existed. All 24 `default` parameter declarations under `vendor/wheels/` were untyped uniformly in the #3302 burn-down; `cli/lucli/services/ArgSpec.cfc` still has typed ones but runs on the Lucee-only LuCLI runtime. + +18. **Adobe 2025's `FileWrite()` appends a trailing `0x0A` when handed a simple value.** `FileWrite(path, "hello world")` puts **12** bytes on disk, not 11. Lucee 6/7, BoxLang and Adobe 2023 write the string verbatim, so local Lucee green does not cover this. Harmless for generated source or JSON; fatal anywhere the read must round-trip what was written, which is why it corrupted every object stored through `wheels.storage.drivers.LocalDisk` (#3302). Decode to binary first — the binary overload has no line-ending behaviour on any engine: + + ```cfm + var payload = IsBinary(content) ? content : CharsetDecode(content, "utf-8"); + FileWrite(path, payload); + ``` + +19. **The `application` scope is unreliable inside `onApplicationEnd()` on Adobe CF 2023.** During `applicationStop()` teardown (triggered by a `?reload` restart or idle-timeout reclaim), bare `application.wo` can resolve against a stale/torn-down scope and land on a Java `String[]`, throwing `Element wo is undefined in a Java object of type class [Ljava.lang.String;` and erroring the whole site until a CF service restart. The only dependable reference at shutdown is the passed-in `arguments.applicationScope`. Always route `onApplicationEnd()` through it and guard with `StructKeyExists` so a partially reclaimed scope degrades to a no-op (#3379). Lucee 6/7 and BoxLang are unaffected; only Adobe CF exhibits this during real teardown. + + ```cfm + // WRONG — bare application.wo breaks on Adobe CF during applicationStop() teardown + public void function onApplicationEnd(struct ApplicationScope) { + application.wo.$include( + template = "../../#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", + argumentCollection = arguments + ); + } + + // RIGHT — route through the passed-in scope and guard before dereferencing + public void function onApplicationEnd(struct ApplicationScope) { + if ( + StructKeyExists(arguments.applicationScope, "wo") + && StructKeyExists(arguments.applicationScope, "wheels") + && StructKeyExists(arguments.applicationScope.wheels, "eventPath") + ) { + arguments.applicationScope.wo.$include( + template = "../../#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", + argumentCollection = arguments + ); + } + } + ``` + + The CLI template (`wheels new`) and the demo app were fixed in #3380. **Existing apps must apply the same change to their `public/Application.cfc`.** Verify Adobe CF fixes locally before pushing — don't iterate via CI: ```bash @@ -57,6 +141,13 @@ curl -s "http://localhost:62023/wheels/core/tests?db=mysql&format=json" | \ python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('totalPass',0),'pass',d.get('totalFail',0),'fail',d.get('totalError',0),'error')" ``` +**Adobe serves cached compiled classes — `?reload=true` does NOT pick up an edited `.cfc`.** `?reload=true` rebuilds the Wheels application scope, not Adobe's template cache, so a source change can keep producing the *old* result for many minutes. This reads exactly like a fix that did not work, and the natural response — reverting or piling on a second change — makes it worse. After editing framework source, `docker restart wheels-adobe2023-1` (or `-adobe2025-1`) before trusting any Adobe result. Lucee and BoxLang pick edits up from the bind mount immediately; only the Adobe legs need this. + +**Narrow the run with `directory=` — it turns a ~19-minute CI round-trip into ~5 seconds.** The core-test endpoint accepts a dotted TestBox scope, allowlisted to `wheels.tests.*` and `vendor..tests.*`. `bundles=` is silently ignored (#3352), so `directory=` is the only working filter. Point it at a *directory*, never a single spec file — a single-file scope discovers 0 bundles and reports green (#3083); check `bundlesDiscovered` in the payload. +```bash +curl -s "http://localhost:62025/wheels/core/tests?db=sqlite&directory=wheels.tests.specs.security&format=json&reload=true" +``` + Deep reference: [.ai/wheels/cross-engine-compatibility.md](.ai/wheels/cross-engine-compatibility.md). ## Anti-Patterns (Top 14) @@ -249,7 +340,11 @@ t.primaryKey(name="userId", autoIncrement=true); For new migrator helpers or anywhere you accept a column-name argument: declare `string columnNames` (NOT `required`), and call `$combineArguments(args=arguments, combine="columnNames,columnName", required=true)` at the top of the body. The pattern is documented in [vendor/wheels/migrator/CLAUDE.md](vendor/wheels/migrator/CLAUDE.md). Boolean nullable flag is `allowNull` everywhere — never `null`. -`t.references()` also respects `useUnderscoreReferenceColumns` (boolean, framework default `false`, `wheels new` template default `true`) — when true it produces `_id` / `_type` columns matching Wheels model `belongsTo` defaults. +`t.references()` also respects `useUnderscoreReferenceColumns` (boolean, framework default `false`, `wheels new` template default `true`) — when true it produces `_id` / `_type` columns instead of `id` / `type`. + +Association foreign-key defaults resolve **either** convention: the default derivation checks which column actually exists on whichever side owns the foreign key, rather than reading the setting ([#3337](https://github.com/wheels-dev/wheels/issues/3337) — before that fix the model layer derived `` unconditionally and a stock `wheels new` app threw `key [id] doesn't exist` on any `include=`). It is schema-driven on purpose: the migrator reads the flag per call, but the model-side default is memoized for the application lifetime, so honouring the flag there would let a runtime flip change migrations without changing models. Apps holding a mix of both shapes work for the same reason. + +**Polymorphic associations are not covered.** `belongsTo(polymorphic=true)` and `hasMany`/`hasOne` with `as=` fix their foreign key to `id` at *registration* time (`vendor/wheels/model/associations.cfc:30`, `:81`, `:134`), before the schema is available, so the join-time resolution never sees a blank to fill. Against an underscore-shaped schema those still need an explicit `foreignKey="_id"`. ## Wheels Conventions @@ -280,6 +375,9 @@ component extends="Model" { // Callbacks beforeSave("sanitizeInput"); + // Calculated SQL properties — select=false keeps them off the default SELECT (hot path) + property(name="fullName", sql="firstName || ' ' || lastName", select=false); + // Query scopes — reusable, composable query fragments scope(name="active", where="status = 'active'"); scope(name="recent", order="createdAt DESC"); @@ -299,6 +397,7 @@ component extends="Model" { Finders: `model("User").findAll()`, `findOne(where="...")`, `findByKey(params.key)`. Create: `model("User").new(params.user).save()`, or `model("User").create(params.user)`. Include associations: `findAll(include="role,orders")`. Pagination: `findAll(page=params.page, perPage=25)`. +Opt a `select=false` calculated property into one call (additive): `findAll(includeCalculated="fullName")`. Unknown names throw `Wheels.CalculatedPropertyNotFound` in dev/testing. ### Scopes / Enums / Builder / Batch @@ -319,7 +418,9 @@ model("User") .orderBy("name", "ASC") .limit(25) .get(); -// Methods: where, orWhere, whereNull, whereNotNull, whereBetween, whereIn, whereNotIn, orderBy, limit, get +// Methods: where, orWhere, whereNull, whereNotNull, whereBetween, whereIn, whereNotIn, orderBy, +// limit, offset, select, include, group, distinct, forUpdate, get +// Any of these (not just where) can START the chain on the model, e.g. model("User").select("id,name").get() // Batch processing — memory-efficient model("User").findEach(batchSize=1000, callback=function(user) { @@ -536,6 +637,8 @@ component extends="wheels.WheelsTest" { - **App tests**: `/wheels/app/tests` — project-specific, in `tests/specs/`. Uses `tests/populate.cfm` and `tests/TestRunner.cfc`. - **Core tests**: `/wheels/core/tests` — framework, in `vendor/wheels/tests/specs/`. Uses `vendor/wheels/tests/populate.cfm`. **This is what CI runs across all engines × DBs.** +**Isolated test application (#3374):** `Application.cfc` includes `vendor/wheels/events/testcontext.cfm` after `config/app.cfm` so runner URLs (and TestClient/browser requests that send `X-Wheels-Test-Context`) bind `_wheelsTest` — a separate CFML application scope. The live `application.wheels` is not swapped. `$testClient(testContext=false)` addresses the live app. A request-scoped overlay cannot replace this (blockers B1–B9 on #3025). Existing apps without the include still use the #3373 named-lock swap on the live scope. + **Critical**: core tests use `directory="wheels.tests.specs"` which compiles EVERY CFC in the directory. One compilation error in any spec file crashes the entire suite for that engine. The "inline closure as constructor named arg" anti-pattern (#5 in Cross-Engine Invariants) is the classic example. ### Test-specific gotchas diff --git a/app/policies/Policy.cfc b/app/policies/Policy.cfc new file mode 100644 index 0000000000..3c3d246f0b --- /dev/null +++ b/app/policies/Policy.cfc @@ -0,0 +1,13 @@ +/** + * This is the parent policy file that all your policies should extend. + * You can add functions to this file to make them available in all your policies. + * Do not delete this file. + * + * Policies are DEFAULT-DENY: every standard action on the wheels.Policy base + * returns false, so each policy must explicitly override a method to grant it. + * Scaffold a policy with `wheels generate policy Post`. + */ +component extends="wheels.Policy" { + + +} diff --git a/cli/README.md b/cli/README.md index f47e993565..bab58fd7c9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -23,7 +23,7 @@ Both installers depend only on Java 21, which is pulled in automatically. ## Commands -See [the CLI command guides](https://guides.wheels.dev/v4-0-0-snapshot/command-line-tools/) or run `wheels --help` in your terminal. +See [the CLI command guides](https://guides.wheels.dev/v4-0-0/command-line-tools/) or run `wheels --help` in your terminal. ## Template Customization @@ -40,4 +40,4 @@ To customize a template: 2. Modify it to match your needs 3. The CLI will automatically use your custom template -See the [Template System Guide](https://guides.wheels.dev/v4-0-0-snapshot/command-line-tools/) for detailed documentation. +See the [Template System Guide](https://guides.wheels.dev/v4-0-0/command-line-tools/) for detailed documentation. diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc index 4667e3f72c..137bab4e1b 100644 --- a/cli/lucli/Module.cfc +++ b/cli/lucli/Module.cfc @@ -276,6 +276,7 @@ component extends="modules.BaseModule" { .option(name = "reporter", default = "simple", description = "Output format: simple, json, or tap") .option(name = "db", default = "sqlite", description = "Database the suite runs against") .option(name = "base-path", default = "", description = "URL prefix the app is mounted under (e.g. /myapp). Auto-derived from WHEELS_SUBPATH or set(subpath=...) when omitted.") + .option(name = "timeout", default = "", description = "Seconds to wait for the suite to finish (default 900). Also settable with WHEELS_TEST_TIMEOUT.") .flag(name = "verbose", default = false, description = "Print per-spec detail instead of the summary rollup") .flag(name = "ci", default = false, description = "CI mode output") .flag(name = "core", default = false, description = "Run the framework core suite (vendor/wheels/tests) instead of the app suite") @@ -507,7 +508,7 @@ component extends="modules.BaseModule" { // ───────────────────────────────────────────────── /** - * hint: Generate Wheels components (model, controller, view, migration, scaffold, route, test, property, api-resource, helper, snippets) + * hint: Generate Wheels components (model, controller, view, migration, scaffold, route, test, property, api-resource, helper, policy, snippets) */ public string function generate() { var args = new services.ArgSpec().toArgv(structuredArgs(arguments)); @@ -527,8 +528,10 @@ component extends="modules.BaseModule" { out(" test Generate a test spec file"); out(" property Generate an add-column migration for a model property"); out(" helper Generate a helper file in app/helpers/"); + out(" policy Generate an authorization policy in app/policies/ (default-deny)"); out(" snippets Generate common code pattern snippets (auth, soft-delete, api, etc.)"); out(" admin Generate admin CRUD interface for an existing model"); + out(" auth Generate a full authentication scaffold (session, token, or JWT)"); out(""); out("Examples:", "bold"); out(" wheels generate app myapp"); @@ -541,8 +544,11 @@ component extends="modules.BaseModule" { out(" wheels generate test model User"); out(" wheels generate property User email:string"); out(" wheels generate helper formatting"); + out(" wheels generate policy Post"); out(" wheels generate snippets auth"); out(" wheels generate admin User"); + out(" wheels generate auth"); + out(" wheels generate auth --strategy=jwt"); return ""; } @@ -584,10 +590,14 @@ component extends="modules.BaseModule" { case "helper": case "h": return generateHelper(remaining); + case "policy": + return generatePolicy(remaining); case "snippets": return generateSnippets(remaining); case "admin": return generateAdmin(remaining); + case "auth": + return generateAuth(remaining); default: out("Unknown generator type: #type#", "red"); out("Run 'wheels generate' for available types."); @@ -729,10 +739,46 @@ component extends="modules.BaseModule" { db = parsed.db, dbExplicit = structKeyExists(arguments.coll, "db"), useTestDB = parsed["test-db"], - basePath = parsed["base-path"] + basePath = parsed["base-path"], + timeout = $resolveTestTimeout(parsed.timeout) }; } + /** + * Seconds to wait for the test-runner response. `--timeout` wins, then + * WHEELS_TEST_TIMEOUT, then 900. + * + * The shared HTTP helper reads for 120 seconds, which is right for the + * request/response bridge commands but is a hard ceiling on how big a suite + * `wheels test` can run: a suite that grows past roughly 140 seconds starts + * failing with `Read timed out` and NO result document at all — not a failure + * report, a crashed runner (issue #3352). The threshold moves with machine + * speed, so a suite can pass locally and fail in CI. A test run is the one + * command here whose duration is expected to scale with the project, so it + * gets its own budget rather than inheriting the bridge default. + * + * Non-numeric or non-positive input falls back to the default rather than + * throwing: a mistyped timeout should not be the thing that stops a test run. + */ + public numeric function $resolveTestTimeout(string parsedTimeout = "") { + if ( + len(trim(arguments.parsedTimeout)) + && isNumeric(trim(arguments.parsedTimeout)) + && val(arguments.parsedTimeout) > 0 + ) { + return val(arguments.parsedTimeout); + } + // mirrors how $resolveTestBasePath() reads WHEELS_SUBPATH + try { + var envValue = createObject("java", "java.lang.System").getenv("WHEELS_TEST_TIMEOUT"); + if (!isNull(envValue) && len(trim(envValue)) && isNumeric(trim(envValue)) && val(envValue) > 0) { + return val(envValue); + } + } catch (any e) { + } + return 900; + } + /** * hint: Run test suite with optional filter and reporter */ @@ -748,6 +794,7 @@ component extends="modules.BaseModule" { var dbExplicit = opts.dbExplicit; var useTestDB = opts.useTestDB; var basePath = opts.basePath; + var timeoutSeconds = opts.timeout; // Default to APP mode unless --core is set explicitly. The previous // auto-detection ("if vendor/wheels/tests/ exists, default to core") @@ -766,7 +813,10 @@ component extends="modules.BaseModule" { // expects. Onboarding finding #2. filter = $normalizeTestFilter(filter, coreTests); - return runTests(filter, reporter, format, verboseOutput, coreTests, db, ciMode, useTestDB, dbExplicit, basePath); + return runTests( + filter, reporter, format, verboseOutput, coreTests, + db, ciMode, useTestDB, dbExplicit, basePath, timeoutSeconds + ); } /** @@ -2565,7 +2615,7 @@ component extends="modules.BaseModule" { // ───────────────────────────────────────────────── /** - * hint: Install, update, and list Wheels packages — use `add` (not `install`) to install + * hint: Add, update, and list Wheels packages (verb is `add`, not `install`) * * The verb is `add`, NOT `install`. Typing `wheels packages install ` * is intercepted by LuCLI's built-in extension installer before dispatch @@ -2666,7 +2716,7 @@ component extends="modules.BaseModule" { var regCli = new modules.wheels.services.packages.PackagesRegistryCli(); return invoke(regCli, regVerb, [opts]); default: - throw(message="Unknown packages subcommand: #sub#"); + throw(message="Unknown packages subcommand: #sub#. The install verb is `add` (not `install`): wheels packages add "); } } @@ -3930,6 +3980,56 @@ component extends="modules.BaseModule" { return ""; } + private string function generatePolicy(required array args) { + // Parse --force flag from the args list + var force = false; + var positional = []; + for (var a in args) { + if (a == "--force") { + force = true; + } else { + arrayAppend(positional, a); + } + } + + if (!arrayLen(positional)) { + out("Usage: wheels generate policy [--force]", "yellow"); + out(" Example: wheels generate policy Post"); + out(""); + out("Writes app/policies/Policy.cfc — default-deny, one method per action."); + out("Enforce with authorize()/can()/policyScope() in your controllers and views."); + return ""; + } + + var codegen = getService("codegen"); + var validation = codegen.validateName(positional[1], "policy"); + if (!validation.valid) { + out("Invalid policy name: #arrayToList(validation.errors, '; ')#", "red"); + return ""; + } + + var result = codegen.generatePolicy(name = positional[1], force = force); + + if (result.success) { + if (structKeyExists(result, "baseCreated") && result.baseCreated) { + printCreated("app/policies/Policy.cfc"); + } + // Derive the actual file name (CodeGen appends the "Policy" suffix) + var fileName = listLast(result.path, "/\"); + printCreated("app/policies/#fileName#"); + + out(""); + out("Policy created! Next steps:", "green"); + out(" 1. Edit app/policies/#fileName# — every action denies until you grant it"); + out(" 2. Enforce in a controller action: authorize(post)"); + out(" 3. Check in views without throwing: can('update', post)"); + out(" 4. Narrow index collections: policyScope(model('#reReplace(fileName, 'Policy\.cfc$', '')#')).findAll()"); + } else { + out(result.error, "red"); + } + return ""; + } + private string function generateSnippets(required array args) { var force = false; var positional = []; @@ -4048,6 +4148,95 @@ component extends="modules.BaseModule" { return ""; } + /** + * Generate a complete authentication scaffold on the wheels.auth + * primitives (issue ##3155). Session strategy (default) emits browser + * login/registration/password-reset; token and jwt emit an API + * sessions controller instead. + */ + private string function generateAuth(array args = []) { + var model = "User"; + var strategy = "session"; + var registration = true; + var force = false; + + for (var arg in arguments.args) { + if (arg == "--force") { + force = true; + } else if (arg == "--registration") { + registration = true; + } else if (arg == "--no-registration") { + registration = false; + } else if (left(arg, 8) == "--model=") { + model = trim(mid(arg, 9, len(arg))); + } else if (left(arg, 11) == "--strategy=") { + strategy = trim(mid(arg, 12, len(arg))); + } else if (left(arg, 2) == "--") { + out("Unknown option: #arg#", "red"); + out("Usage: wheels generate auth [ModelName] [--model=User] [--strategy=session|token|jwt] [--registration|--no-registration] [--force]", "yellow"); + throw(type = "Wheels.InvalidArguments", message = "Unknown option for generate auth: #arg#"); + } else { + // First bare positional is the model name (same as --model=). + model = trim(arg); + } + } + + if (!len(model)) { + model = "User"; + } + if (!listFindNoCase("session,token,jwt", strategy)) { + out("Unknown strategy: #strategy# (valid: session, token, jwt)", "red"); + throw(type = "Wheels.InvalidArguments", message = "Unknown auth strategy: #strategy#. Valid strategies: session, token, jwt."); + } + + out("Generating #strategy# authentication for #capitalize(model)#...", "cyan"); + out(""); + + var scaffold = getService("scaffold"); + var results = scaffold.generateAuth( + model = model, + strategy = strategy, + registration = registration, + force = force, + cliVersion = super.version() + ); + + if (results.success) { + for (var item in results.generated) { + var relPath = replace(item.path, variables.projectRoot & "/", ""); + printCreated("#item.type#: #relPath#"); + } + for (var note in results.skipped ?: []) { + out(" skip #note#", "yellow"); + } + out(""); + out("Authentication scaffold complete! Next steps:", "green"); + out(" 1. Run the migration: wheels migrate latest"); + if (strategy == "session") { + out(" 2. Restart or reload, then visit /login (and /register)."); + out(" 3. Protect actions with a filter that calls service(""authenticator"").authenticate(request)."); + out(" 4. Wire reset-link email delivery in app/controllers/Passwords.cfc (see the TODO in create()) —"); + out(" until then no reset email is actually sent."); + out(" 5. Rate-limit POST /login in production (wheels.middleware.RateLimiter) — each attempt runs a full PBKDF2 derivation."); + } else if (strategy == "jwt") { + out(" 2. Set WHEELS_JWT_SECRET in .env (at least 32 random bytes) — startup fails loudly without it."); + out(" 3. Restart, then POST credentials to /api/session to receive a JWT."); + out(" 4. Rate-limit POST /api/session in production (wheels.middleware.RateLimiter) — each attempt runs a full PBKDF2 derivation."); + } else { + out(" 2. Restart, then POST credentials to /api/session to receive a bearer token."); + out(" 3. Rate-limit POST /api/session in production (wheels.middleware.RateLimiter) — each attempt runs a full PBKDF2 derivation."); + } + out(" Generated code is yours to edit — re-run with --force and review `git diff` to upgrade."); + } else { + out("Auth generation failed:", "red"); + for (var err in results.errors) { + out(" #err#", "red"); + } + } + + return ""; + } + /** * List all available snippet patterns */ @@ -5561,7 +5750,8 @@ component extends="modules.BaseModule" { boolean ciMode = false, boolean useTestDB = true, boolean dbExplicit = false, - string basePath = "" + string basePath = "", + numeric timeoutSeconds = 900 ) { var serverPort = $requireRunningServer([ "Start one with: wheels start", @@ -5630,7 +5820,7 @@ component extends="modules.BaseModule" { testUrl &= "&directory=#filter#"; } - var httpResult = makeHttpRequest(testUrl); + var httpResult = makeHttpRequest(testUrl, arguments.timeoutSeconds * 1000); // Try to parse JSON result if (isJSON(httpResult)) { @@ -5673,7 +5863,18 @@ component extends="modules.BaseModule" { } } catch (any e) { runState.crashed = true; - out("Test execution failed: #e.message#", "red"); + // A read timeout here is indistinguishable from a hung app to anyone who has not + // seen it before, because the runner produced no document at all — the suite may + // well have passed (issue #3352). Say which side gave up, and how to give it longer. + if (reFindNoCase("(read timed out|SocketTimeout)", e.message)) { + out("Test run timed out after #arguments.timeoutSeconds#s waiting for the suite to finish.", "red"); + out("The specs may have passed — the CLI stopped waiting, the runner did not stop running.", "yellow"); + out("Give it longer: wheels test --timeout=#arguments.timeoutSeconds * 2#", "yellow"); + out("Or set WHEELS_TEST_TIMEOUT= for the whole environment.", "yellow"); + out("Or scope the run: wheels test --filter=", "yellow"); + } else { + out("Test execution failed: #e.message#", "red"); + } } // Exit non-zero when specs failed/errored so CI and shells can detect it. @@ -6191,7 +6392,27 @@ component extends="modules.BaseModule" { fileWrite( targetDir & "/app/views/main/index.cfm", - '

Welcome to ' & appName & '

' & nl & '

Your Wheels application is running. Edit this file at app/views/main/index.cfm

' & nl + ( + '' & nl & + '' & nl & + '

Welcome to ' & appName & '

' & nl & + '

Your Wheels ##get("version")## application is running on ##application.wheels.serverName## with ##application.wheels.dataSourceName## (##get("environment")##).

' & nl & + nl & + '

Next steps

' & nl & + '
    ' & nl & + tab & '
  • wheels g scaffold Post title content:text — generate a model, controller, and views
  • ' & nl & + tab & '
  • wheels migrate latest — build the database schema
  • ' & nl & + tab & '
  • wheels test — run the test suite
  • ' & nl & + '
' & nl & + '

This page lives at app/views/main/index.cfm; routing is in config/routes.cfm.

' & nl & + '
' & nl + ) ); printCreated(appName & "/app/views/main/index.cfm"); @@ -6409,7 +6630,7 @@ component extends="modules.BaseModule" { out(" unzip wheels-core-.zip -d ~/.wheels/modules/wheels/vendor/"); out(" wheels new #appName#"); out(""); - out("See: https://guides.wheels.dev/v4-0-0-snapshot/start-here/installing/"); + out("See: https://guides.wheels.dev/v4-0-0/start-here/installing/"); throw( type="Wheels.FrameworkNotFound", @@ -7416,8 +7637,13 @@ component extends="modules.BaseModule" { return result; } - private string function makeHttpRequest(required string requestUrl) { - return makeHttpRequestWithStatus(arguments.requestUrl).body; + /** + * @readTimeout Milliseconds to wait for the response. Defaults to the + * request/response bridge budget; long-running callers such as + * `wheels test` pass their own (issue #3352). + */ + private string function makeHttpRequest(required string requestUrl, numeric readTimeout = 120000) { + return makeHttpRequestWithStatus(requestUrl = arguments.requestUrl, readTimeout = arguments.readTimeout).body; } /** @@ -7433,14 +7659,15 @@ component extends="modules.BaseModule" { */ private struct function makeHttpRequestWithStatus( required string requestUrl, - boolean followRedirects = true + boolean followRedirects = true, + numeric readTimeout = 120000 ) { var javaUrl = createObject("java", "java.net.URL").init(arguments.requestUrl); var conn = javaUrl.openConnection(); conn.setRequestMethod("GET"); conn.setInstanceFollowRedirects(javacast("boolean", arguments.followRedirects)); conn.setConnectTimeout(5000); - conn.setReadTimeout(120000); + conn.setReadTimeout(javacast("int", arguments.readTimeout)); var responseCode = conn.getResponseCode(); var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream(); @@ -7561,7 +7788,8 @@ component extends="modules.BaseModule" { variables.services.scaffold = new services.Scaffold( codeGenService = getService("codegen"), helpers = getService("helpers"), - projectRoot = variables.projectRoot + projectRoot = variables.projectRoot, + moduleRoot = variables.moduleRoot ); break; case "analysis": @@ -7854,7 +8082,8 @@ component extends="modules.BaseModule" { var testUrl = "http://localhost:#serverPort##runnerPath#?db=sqlite&format=json&directory=#directory#"; try { - var httpResult = makeHttpRequest(testUrl); + // same long-running suite over the same 120s-default helper (issue #3352) + var httpResult = makeHttpRequest(testUrl, $resolveTestTimeout() * 1000); } catch (any e) { out("Failed to reach test runner at: #testUrl#", "red"); out("Is the server running? Try: wheels start", "yellow"); diff --git a/cli/lucli/services/CodeGen.cfc b/cli/lucli/services/CodeGen.cfc index b97afaf1f0..624fa201f8 100644 --- a/cli/lucli/services/CodeGen.cfc +++ b/cli/lucli/services/CodeGen.cfc @@ -385,6 +385,59 @@ component { return result; } + /** + * Generate an authorization policy CFC file (issue #3156). + * + * Writes app/policies/Policy.cfc with every standard action + * denying (policies are default-deny) plus commented grant examples. Also + * scaffolds the app-level app/policies/Policy.cfc base stub when missing so + * `extends="Policy"` resolves (mirrors app/models/Model.cfc). + */ + public struct function generatePolicy( + required string name, + string description = "", + boolean force = false + ) { + var modelName = variables.helpers.capitalize(arguments.name); + // Accept both "Post" and "PostPolicy" — normalize to the model name. + if (reFindNoCase("Policy$", modelName) && len(modelName) > 6) { + modelName = left(modelName, len(modelName) - 6); + } + var policyName = modelName & "Policy"; + var filePath = variables.projectRoot & "/app/policies/#policyName#.cfc"; + + if (fileExists(filePath) && !arguments.force) { + return {success: false, error: "Policy already exists: app/policies/#policyName#.cfc", path: filePath, baseCreated: false}; + } + + // Ensure the parent Policy.cfc stub exists. Never overwritten. + var baseCreated = false; + if (!fileExists(variables.projectRoot & "/app/policies/Policy.cfc")) { + var baseResult = variables.templateService.generateFromTemplate( + template = "PolicyBaseContent.txt", + destination = "app/policies/Policy.cfc", + context = {timestamp: dateTimeFormat(now(), "yyyy-mm-dd HH:nn:ss")} + ); + baseCreated = baseResult.success; + } + + var context = { + policyName: policyName, + modelName: modelName, + description: arguments.description, + timestamp: dateTimeFormat(now(), "yyyy-mm-dd HH:nn:ss") + }; + + var result = variables.templateService.generateFromTemplate( + template = "PolicyContent.txt", + destination = "app/policies/#policyName#.cfc", + context = context + ); + result.baseCreated = baseCreated; + + return result; + } + /** * Validate name for code generation */ diff --git a/cli/lucli/services/Doctor.cfc b/cli/lucli/services/Doctor.cfc index 46875fb5c2..135d110b24 100644 --- a/cli/lucli/services/Doctor.cfc +++ b/cli/lucli/services/Doctor.cfc @@ -660,7 +660,7 @@ component { recs, "Install or reinstall the Wheels CLI with a complete distribution, " & "or set WHEELS_FRAMEWORK_PATH to a vendor/wheels/ directory. " - & "See: https://guides.wheels.dev/v4-0-0-snapshot/start-here/installing/" + & "See: https://guides.wheels.dev/v4-0-0/start-here/installing/" ); } else if (findNoCase("Missing required directory", combined)) { arrayAppend(recs, "Run 'wheels new' to scaffold a complete project structure"); diff --git a/cli/lucli/services/Scaffold.cfc b/cli/lucli/services/Scaffold.cfc index 3df5497f1f..4c4281d639 100644 --- a/cli/lucli/services/Scaffold.cfc +++ b/cli/lucli/services/Scaffold.cfc @@ -11,11 +11,16 @@ component { public function init( required any codeGenService, required any helpers, - required string projectRoot + required string projectRoot, + string moduleRoot = "" ) { variables.codeGenService = arguments.codeGenService; variables.helpers = arguments.helpers; variables.projectRoot = arguments.projectRoot; + // Optional: only needed by generators that read bundled template + // directories directly (generateAuth). Ends with a trailing slash + // when provided (same convention as the Admin service). + variables.moduleRoot = arguments.moduleRoot; return this; } @@ -614,8 +619,473 @@ component { return {success: true, path: filePath, message: "Generated API controller test"}; } + /** + * Generate a complete authentication scaffold over the wheels.auth + * primitives (issue ##3155): User model with PBKDF2 password hashing, + * sessions/passwords/registrations controllers + views (session + * strategy), or an api/Sessions controller (token/jwt strategies), + * a create-table migration, marked route/service/strategy blocks + * injected into config + app events, and generated app specs. + * + * Generated code is code-you-own: every file carries a stamped header + * and re-running with force=true regenerates it (marker blocks are + * replaced in place, never duplicated). + */ + public struct function generateAuth( + string model = "User", + string strategy = "session", + boolean registration = true, + boolean force = false, + string cliVersion = "" + ) { + var results = {success: true, generated: [], skipped: [], errors: [], rollback: []}; + var nl = chr(10); + var t = chr(9); + + var strategyName = lCase(trim(arguments.strategy)); + if (!listFindNoCase("session,token,jwt", strategyName)) { + throw( + type = "Wheels.InvalidArguments", + message = "Unknown auth strategy: #arguments.strategy#. Valid strategies: session, token, jwt." + ); + } + if (!len(variables.moduleRoot)) { + throw( + type = "Wheels.InvalidArguments", + message = "The Scaffold service needs a moduleRoot to locate the auth templates." + ); + } + + var modelName = variables.helpers.capitalize(trim(arguments.model)); + var modelVar = lCase(left(modelName, 1)) & (len(modelName) > 1 ? mid(modelName, 2, len(modelName)) : ""); + var tableName = lCase(variables.helpers.pluralize(modelName)); + var isApi = strategyName != "session"; + var withRegistration = arguments.registration && !isApi; + + var ctx = { + modelName: modelName, + modelVar: modelVar, + tableName: tableName, + strategy: strategyName, + cliVersion: len(arguments.cliVersion) ? arguments.cliVersion : "dev", + generatedDate: dateFormat(now(), "yyyy-mm-dd") + }; + ctx.protectedApiToken = strategyName == "token" ? ",apiTokenDigest" : ""; + ctx.apiTokenMethods = strategyName == "token" ? $renderAuthTemplate("api-token-methods", ctx) : ""; + ctx.apiTokenColumn = strategyName == "token" + ? t & t & t & t & 't.string(columnNames="apiTokenDigest", allowNull=true, limit=64);' & nl + : ""; + // Emits `#linkTo(...)#` into the login view (## collapses to # in this + // CFC's string literal; the .txt templates are raw and keep single #). + ctx.registrationLink = withRegistration + ? '
##linkTo(route="register", text="Create an account")##' + : ""; + + try { + // 1. Model + $writeAuthFile( + relPath = "app/models/#modelName#.cfc", + content = $renderAuthTemplate("model", ctx), + force = arguments.force, + results = results, + label = "model" + ); + + // 2. Migration. Never overwritten (even with force) — rewriting an + // already-applied migration would desync the tracking table. + if (!migrationAlreadyExists(modelName)) { + var migrationDir = variables.projectRoot & "/app/migrator/migrations"; + if (!directoryExists(migrationDir)) { + directoryCreate(migrationDir, true); + } + var migrationPath = migrationDir & "/" & variables.helpers.generateMigrationTimestamp() + & "_create_" & tableName & "_table.cfc"; + fileWrite(migrationPath, $renderAuthTemplate("migration", ctx)); + arrayAppend(results.generated, {type: "migration", path: migrationPath}); + arrayAppend(results.rollback, migrationPath); + } else { + arrayAppend(results.skipped, "migration: create_#tableName#_table already exists (never overwritten — edit it directly)"); + } + + // 3. Controllers + views + specs per strategy + if (isApi) { + $writeAuthFile( + relPath = "app/controllers/api/Sessions.cfc", + content = $renderAuthTemplate("controller-api-sessions-#strategyName#", ctx), + force = arguments.force, + results = results, + label = "controller" + ); + $writeAuthFile( + relPath = "tests/specs/controllers/ApiSessionsControllerSpec.cfc", + content = $renderAuthTemplate("spec-api-sessions", ctx), + force = arguments.force, + results = results, + label = "test" + ); + arrayAppend(results.skipped, "registration: not applicable to the #strategyName# strategy (no browser sign-up flow)"); + } else { + $writeAuthFile( + relPath = "app/controllers/Sessions.cfc", + content = $renderAuthTemplate("controller-sessions", ctx), + force = arguments.force, + results = results, + label = "controller" + ); + $writeAuthFile( + relPath = "app/controllers/Passwords.cfc", + content = $renderAuthTemplate("controller-passwords", ctx), + force = arguments.force, + results = results, + label = "controller" + ); + $writeAuthFile( + relPath = "app/views/sessions/new.cfm", + content = $renderAuthTemplate("view-sessions-new", ctx), + force = arguments.force, + results = results, + label = "view" + ); + $writeAuthFile( + relPath = "app/views/passwords/new.cfm", + content = $renderAuthTemplate("view-passwords-new", ctx), + force = arguments.force, + results = results, + label = "view" + ); + $writeAuthFile( + relPath = "app/views/passwords/edit.cfm", + content = $renderAuthTemplate("view-passwords-edit", ctx), + force = arguments.force, + results = results, + label = "view" + ); + if (withRegistration) { + $writeAuthFile( + relPath = "app/controllers/Registrations.cfc", + content = $renderAuthTemplate("controller-registrations", ctx), + force = arguments.force, + results = results, + label = "controller" + ); + $writeAuthFile( + relPath = "app/views/registrations/new.cfm", + content = $renderAuthTemplate("view-registrations-new", ctx), + force = arguments.force, + results = results, + label = "view" + ); + } + $writeAuthFile( + relPath = "tests/specs/controllers/SessionsControllerSpec.cfc", + content = $renderAuthTemplate("spec-sessions-controller", ctx), + force = arguments.force, + results = results, + label = "test" + ); + } + + // Model spec (all strategies) + $writeAuthFile( + relPath = "tests/specs/models/#modelName#AuthSpec.cfc", + content = $renderAuthTemplate("spec-model", ctx), + force = arguments.force, + results = results, + label = "test" + ); + + // 4. Routes — marked block, replaced in place on --force. + var routesBlock = ""; + if (isApi) { + routesBlock = $renderAuthTemplate("routes-api", ctx); + } else { + ctx.registrationRoutes = withRegistration ? $renderAuthTemplate("routes-registration", ctx) : ""; + routesBlock = $renderAuthTemplate("routes-session", ctx); + } + $injectAuthBlock( + relPath = "config/routes.cfm", + block = routesBlock, + beginMarker = "// wheels:generate-auth:routes:begin", + endMarker = "// wheels:generate-auth:routes:end", + force = arguments.force, + results = results, + anchorMode = "routes", + label = "routes" + ); + + // 5. Service registrations — config/services.cfm (created if absent). + $injectAuthBlock( + relPath = "config/services.cfm", + block = $renderAuthTemplate(isApi ? "services-api" : "services-session", ctx), + beginMarker = "// wheels:generate-auth:services:begin", + endMarker = "// wheels:generate-auth:services:end", + force = arguments.force, + results = results, + anchorMode = "cfscript", + label = "services" + ); + + // 6. Strategy wiring — app/events/onapplicationstart.cfm (the DI + // container isn't available yet in config/app.cfm; see the auth + // chapter in the guides). + $injectAuthBlock( + relPath = "app/events/onapplicationstart.cfm", + block = $renderAuthTemplate("bootstrap-#strategyName#", ctx), + beginMarker = "// wheels:generate-auth:strategy:begin", + endMarker = "// wheels:generate-auth:strategy:end", + force = arguments.force, + results = results, + anchorMode = "cfscript", + label = "strategy" + ); + } catch (any e) { + results.success = false; + arrayAppend(results.errors, e.message); + // Roll back on ANY failure, not just typed ScaffoldErrors — an IO + // error mid-run must not leave a half-generated scaffold behind. + // The rollback list only ever contains files THIS run created, so + // pre-existing user files are never deleted. + rollbackScaffold(results.rollback); + } + + return results; + } + // ── Private helpers ────────────────────────────── + /** + * Read and render a template from cli/lucli/templates/auth/. + * Simple {{key}} replacement — values are inserted verbatim. + */ + private string function $renderAuthTemplate(required string template, required struct context) { + var path = variables.moduleRoot & "templates/auth/" & arguments.template & ".txt"; + if (!fileExists(path)) { + throw(type = "ScaffoldError", message = "Auth template not found: #path#"); + } + var content = fileRead(path); + for (var key in arguments.context) { + if (isSimpleValue(arguments.context[key])) { + content = replaceNoCase(content, "{{" & key & "}}", arguments.context[key], "all"); + } + } + return content; + } + + /** + * Write a generated auth file. Existing files are skipped unless force + * is set; only newly created files are registered for rollback so a + * failed run never deletes a user's pre-existing file. + */ + private boolean function $writeAuthFile( + required string relPath, + required string content, + required boolean force, + required struct results, + required string label + ) { + var absPath = variables.projectRoot & "/" & arguments.relPath; + var existed = fileExists(absPath); + if (existed && !arguments.force) { + arrayAppend(arguments.results.skipped, "#arguments.label#: #arguments.relPath# already exists (use --force to overwrite)"); + return false; + } + var dir = getDirectoryFromPath(absPath); + if (!directoryExists(dir)) { + directoryCreate(dir, true); + } + fileWrite(absPath, arguments.content); + arrayAppend(arguments.results.generated, {type: arguments.label, path: absPath}); + if (!existed) { + arrayAppend(arguments.results.rollback, absPath); + } + return true; + } + + /** + * Inject (or, with force, replace in place) a marker-delimited block into + * a config file. anchorMode "routes" inserts inside the mapper() chain — + * at // CLI-Appends-Here, else before .root(), else before the last + * .end() — so the auth routes always precede root/wildcard. anchorMode + * "cfscript" inserts before the file's closing cfscript end tag + * (creating the file with a cfscript wrapper when absent). Tag tokens + * are chr(60)-concatenated below — a literal tag in a string or + * comment trips Lucee's tag scanner and crashes the whole bundle. + */ + private void function $injectAuthBlock( + required string relPath, + required string block, + required string beginMarker, + required string endMarker, + required boolean force, + required struct results, + required string anchorMode, + required string label + ) { + var nl = chr(10); + var t = chr(9); + var scriptOpenTag = chr(60) & "cfscript" & chr(62); + var scriptCloseTag = chr(60) & "/cfscript" & chr(62); + var absPath = variables.projectRoot & "/" & arguments.relPath; + var blockText = reReplace(arguments.block, "[\r\n]+$", ""); + + if (!fileExists(absPath)) { + if (arguments.anchorMode == "cfscript") { + var dir = getDirectoryFromPath(absPath); + if (!directoryExists(dir)) { + directoryCreate(dir, true); + } + fileWrite(absPath, scriptOpenTag & nl & $indentBlock(blockText, t) & nl & scriptCloseTag & nl); + arrayAppend(arguments.results.generated, {type: arguments.label, path: absPath}); + arrayAppend(arguments.results.rollback, absPath); + return; + } + arrayAppend( + arguments.results.skipped, + "#arguments.label#: #arguments.relPath# not found — add this block manually inside the mapper() chain, before .root()/.wildcard():" & nl & blockText + ); + return; + } + + var content = fileRead(absPath); + var beginPos = find(arguments.beginMarker, content); + + // Replace an existing block in place (idempotent under --force). + if (beginPos > 0) { + if (!arguments.force) { + arrayAppend(arguments.results.skipped, "#arguments.label#: block already present in #arguments.relPath# (use --force to regenerate)"); + return; + } + var endPos = find(arguments.endMarker, content, beginPos); + if (endPos == 0) { + arrayAppend(arguments.results.skipped, "#arguments.label#: begin marker without matching end marker in #arguments.relPath# — fix the file manually"); + return; + } + var regionStart = $lineStart(content, beginPos); + var regionEnd = $lineEnd(content, endPos + len(arguments.endMarker) - 1); + var indent = $lineIndent(content, beginPos); + content = left(content, regionStart - 1) + & $indentBlock(blockText, indent) & nl + & mid(content, regionEnd + 1, len(content)); + fileWrite(absPath, content); + arrayAppend(arguments.results.generated, {type: arguments.label, path: absPath}); + return; + } + + // First-time insertion. + if (arguments.anchorMode == "routes") { + var anchorPos = find("// CLI-Appends-Here", content); + if (anchorPos == 0) { + // Skip commented-out `.root(` lines (anti-pattern ##14) — the + // stock routes.cfm ships a commented example above the real one. + anchorPos = $findCodePosition(content, ".root("); + } + // Deliberately NO `.end()` fallback: the last `.end()` closes the + // mapper chain AFTER `.wildcard()`, so routes inserted there could + // never match (anti-pattern ##6). When neither anchor exists, make + // the user place the block instead of injecting dead routes. + if (anchorPos == 0) { + arrayAppend( + arguments.results.skipped, + "#arguments.label#: could not find an insertion anchor (// CLI-Appends-Here or an uncommented .root()) in #arguments.relPath# — add this block manually inside the mapper() chain, before .root()/.wildcard():" & nl & blockText + ); + return; + } + var insertLineStart = $lineStart(content, anchorPos); + var anchorIndent = $lineIndent(content, anchorPos); + content = left(content, insertLineStart - 1) + & $indentBlock(blockText, anchorIndent) & nl + & mid(content, insertLineStart, len(content)); + fileWrite(absPath, content); + arrayAppend(arguments.results.generated, {type: arguments.label, path: absPath}); + return; + } + + // cfscript mode: insert before the last closing tag, else append a block. + var closePos = content.lastIndexOf(scriptCloseTag); + if (closePos >= 0) { + content = left(content, closePos) + & nl & $indentBlock(blockText, t) & nl + & mid(content, closePos + 1, len(content)); + } else { + content = content & nl & scriptOpenTag & nl & $indentBlock(blockText, t) & nl & scriptCloseTag & nl; + } + fileWrite(absPath, content); + arrayAppend(arguments.results.generated, {type: arguments.label, path: absPath}); + } + + /** + * Position of the first occurrence of needle that is NOT on a + * line-comment (`// ...`) portion of its line. Returns 0 when only + * commented occurrences exist. + */ + private numeric function $findCodePosition(required string content, required string needle) { + var pos = find(arguments.needle, arguments.content); + while (pos > 0) { + var lineStartPos = $lineStart(arguments.content, pos); + var linePrefix = mid(arguments.content, lineStartPos, pos - lineStartPos); + if (!find("//", linePrefix)) { + return pos; + } + pos = find(arguments.needle, arguments.content, pos + 1); + } + return 0; + } + + /** + * 1-based index of the first character of the line containing pos. + */ + private numeric function $lineStart(required string content, required numeric pos) { + var i = arguments.pos; + while (i > 1 && mid(arguments.content, i - 1, 1) != chr(10)) { + i--; + } + return i; + } + + /** + * 1-based index of the newline terminating the line containing pos + * (or of the last character when the file ends without one). + */ + private numeric function $lineEnd(required string content, required numeric pos) { + var i = arguments.pos; + var total = len(arguments.content); + while (i <= total && mid(arguments.content, i, 1) != chr(10)) { + i++; + } + return i > total ? total : i; + } + + /** + * Leading whitespace of the line containing pos. + */ + private string function $lineIndent(required string content, required numeric pos) { + var i = $lineStart(arguments.content, arguments.pos); + var total = len(arguments.content); + var indent = ""; + while (i <= total) { + var ch = mid(arguments.content, i, 1); + if (ch == chr(9) || ch == " ") { + indent &= ch; + i++; + } else { + break; + } + } + return indent; + } + + /** + * Prefix every non-empty line of a block with the given indentation. + */ + private string function $indentBlock(required string block, required string indent) { + var lines = listToArray(replace(arguments.block, chr(13), "", "all"), chr(10), true); + var indented = []; + for (var line in lines) { + arrayAppend(indented, len(trim(line)) ? arguments.indent & line : line); + } + return arrayToList(indented, chr(10)); + } + /** * Detect the indentation used before a given position in content */ diff --git a/cli/lucli/templates/app/app/jobs/README.md b/cli/lucli/templates/app/app/jobs/README.md index 69e3c522b9..39ba8ac7e3 100644 --- a/cli/lucli/templates/app/app/jobs/README.md +++ b/cli/lucli/templates/app/app/jobs/README.md @@ -48,4 +48,4 @@ wheels generate migration create_wheels_jobs_table wheels migrate latest ``` -See [Background Jobs](https://wheels.dev/v4-0-0-snapshot/digging-deeper/) in the guides for retries, backoff, priority queues, and the monitoring dashboard. +See [Background Jobs](https://guides.wheels.dev/v4-0-0/digging-deeper/background-jobs/) in the guides for retries, backoff, priority queues, and the monitoring dashboard. diff --git a/cli/lucli/templates/app/app/mailers/README.md b/cli/lucli/templates/app/app/mailers/README.md index 82c52e0bd8..d1bc4c6b07 100644 --- a/cli/lucli/templates/app/app/mailers/README.md +++ b/cli/lucli/templates/app/app/mailers/README.md @@ -31,4 +31,4 @@ set(mailerSettings = { }); ``` -See [Sending Email](https://wheels.dev/v4-0-0-snapshot/digging-deeper/sending-email/) in the guides for the full walkthrough. +See [Sending Email](https://guides.wheels.dev/v4-0-0/digging-deeper/sending-email/) in the guides for the full walkthrough. diff --git a/cli/lucli/templates/app/app/plugins/README.md b/cli/lucli/templates/app/app/plugins/README.md index e158020221..f2183fccae 100644 --- a/cli/lucli/templates/app/app/plugins/README.md +++ b/cli/lucli/templates/app/app/plugins/README.md @@ -17,7 +17,7 @@ wheels stop && wheels start Note: the install verb is `add`, not `install`. -See [Packages](https://wheels.dev/v4-0-0-snapshot/digging-deeper/) in the guides for details. +See [Packages](https://guides.wheels.dev/v4-0-0/digging-deeper/packages/) in the guides for details. ## Migrating from a 3.x plugin diff --git a/cli/lucli/templates/app/app/policies/Policy.cfc b/cli/lucli/templates/app/app/policies/Policy.cfc new file mode 100644 index 0000000000..3c3d246f0b --- /dev/null +++ b/cli/lucli/templates/app/app/policies/Policy.cfc @@ -0,0 +1,13 @@ +/** + * This is the parent policy file that all your policies should extend. + * You can add functions to this file to make them available in all your policies. + * Do not delete this file. + * + * Policies are DEFAULT-DENY: every standard action on the wheels.Policy base + * returns false, so each policy must explicitly override a method to grant it. + * Scaffold a policy with `wheels generate policy Post`. + */ +component extends="wheels.Policy" { + + +} diff --git a/cli/lucli/templates/app/app/snippets/ConfigRoutes.txt b/cli/lucli/templates/app/app/snippets/ConfigRoutes.txt index 4522a53d28..7220374991 100644 --- a/cli/lucli/templates/app/app/snippets/ConfigRoutes.txt +++ b/cli/lucli/templates/app/app/snippets/ConfigRoutes.txt @@ -2,7 +2,7 @@ // Use this file to add routes to your application and point the root route to a controller action. // Don't forget to issue a reload request (e.g. reload=true) after making changes. - // See https://guides.wheels.dev/v4-0-0-snapshot/handling-requests-with-controllers/routing for more info. + // See https://guides.wheels.dev/v4-0-0/basics/routing/ for more info. mapper() // CLI-Appends-Here diff --git a/cli/lucli/templates/app/config/environment.cfm b/cli/lucli/templates/app/config/environment.cfm index 222ae78a50..df4174e63b 100644 --- a/cli/lucli/templates/app/config/environment.cfm +++ b/cli/lucli/templates/app/config/environment.cfm @@ -2,7 +2,7 @@ // Use this file to set the current environment for your application. // You can set it to "development", "testing", "maintenance" or "production". // Don't forget to issue a reload request (e.g. reload=true) after making changes. -// See https://guides.wheels.dev/v4-0-0-snapshot/working-with-wheels/switching-environments for more info. +// See https://guides.wheels.dev/v4-0-0/core-concepts/environments-and-configuration/ for more info. // Below, we have set it to "development" for you since that is convenient when you are building your application. // We recommend that you change this to "production" when you're running your application live. diff --git a/cli/lucli/templates/app/config/routes.cfm b/cli/lucli/templates/app/config/routes.cfm index 283b3af077..a55cb64bf5 100644 --- a/cli/lucli/templates/app/config/routes.cfm +++ b/cli/lucli/templates/app/config/routes.cfm @@ -2,7 +2,7 @@ // Use this file to add routes to your application and point the root route to a controller action. // Don't forget to issue a reload request (e.g. reload=true) after making changes. - // See https://guides.wheels.dev/v4-0-0-snapshot/handling-requests-with-controllers/routing for more info. + // See https://guides.wheels.dev/v4-0-0/basics/routing/ for more info. mapper() // CLI-Appends-Here diff --git a/cli/lucli/templates/app/config/settings.cfm b/cli/lucli/templates/app/config/settings.cfm index c13d1d528f..27124f6d5a 100644 --- a/cli/lucli/templates/app/config/settings.cfm +++ b/cli/lucli/templates/app/config/settings.cfm @@ -3,7 +3,7 @@ Use this file to configure your application. You can also use the environment specific files (e.g. /config/production/settings.cfm) to override settings set here. Don't forget to issue a reload request (e.g. reload=true) after making changes. - See https://guides.wheels.dev/v4-0-0-snapshot/working-with-wheels/configuration-and-defaults for more info. + See https://guides.wheels.dev/v4-0-0/core-concepts/environments-and-configuration/ for more info. */ /* diff --git a/cli/lucli/templates/app/public/Application.cfc b/cli/lucli/templates/app/public/Application.cfc index 0a38c6aace..05388f2af8 100644 --- a/cli/lucli/templates/app/public/Application.cfc +++ b/cli/lucli/templates/app/public/Application.cfc @@ -2,7 +2,16 @@ component output="false" { // Put variables we just need internally inside a wheels struct. this.wheels = {}; - this.wheels.rootPath = GetDirectoryFromPath(GetBaseTemplatePath()); + // Anchor to THIS file's directory (the public front-controller dir), not the + // base template's. GetBaseTemplatePath() returns whatever file was originally + // requested, so when a request bootstraps under a subfolder (e.g. the test + // runner) rootPath would mis-anchor — and since it seeds `this.name` via + // Hash(rootPath) below, an unstable value silently splits one app across two + // application scopes (the "reload=true fixes it" symptom in issue #3025/#2887). + // GetCurrentTemplatePath() is always this Application.cfc's path, so rootPath + // stays stable regardless of the requested base template — and is identical to + // the old value for a normal front-controller request. + this.wheels.rootPath = GetDirectoryFromPath(GetCurrentTemplatePath()); this.name = createUUID(); @@ -100,6 +109,13 @@ component output="false" { include "../config/app.cfm"; + // Issue #3374: bind test-runner / TestClient / browser requests to a + // separate CFML application name so the live application.wheels is never + // swapped. No-op when vendor/wheels is absent. + if (FileExists(GetDirectoryFromPath(GetCurrentTemplatePath()) & "../vendor/wheels/events/testcontext.cfm")) { + include "../vendor/wheels/events/testcontext.cfm"; + } + function onApplicationStart() { application.env = duplicate(this.env); @@ -147,10 +163,26 @@ component output="false" { } } - application.wo.$include( - template = "../../#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", - argumentCollection = arguments - ); + // Run the framework's onApplicationEnd event through the Wheels global. + // During applicationStop() teardown on Adobe CF 2023 the LIVE `application` + // scope is unreliable — bare `application.wo` can resolve against a + // stale/torn-down scope and land on a Java String[], throwing "Element wo + // is undefined in a Java object of type class [Ljava.lang.String;" and + // erroring the whole site until a CF service restart (issue #3379). The + // passed-in arguments.applicationScope is the only dependable reference at + // shutdown (it is what the $wheelsBrowserLauncher cleanup above uses), so + // route the call through it and guard so a partially reclaimed scope + // degrades to a no-op instead of a hard error. + if ( + StructKeyExists(arguments.applicationScope, "wo") + && StructKeyExists(arguments.applicationScope, "wheels") + && StructKeyExists(arguments.applicationScope.wheels, "eventPath") + ) { + arguments.applicationScope.wo.$include( + template = "#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", + argumentCollection = arguments + ); + } } public void function onSessionStart() { @@ -321,6 +353,25 @@ component output="false" { // Fail silently if logging fails } } + // Record WHY a requested reload did not fire so the framework's debug + // bar can render a development-only notice instead of a silent no-op + // (issue #3311). Recording is environment-agnostic — a request-scope + // flag, no output; the message text and the development-environment + // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm + // so wording can improve without template drift. Wrong-password and + // rate-limited attempts deliberately collapse into one generic reason + // so the notice adds no oracle on top of $secureCompare(). + if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) { + local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword); + if (!local.reloadPasswordConfigured) { + request.wheels.reloadRefusedReason = "emptyPassword"; + } else if (!StructKeyExists(url, "password")) { + request.wheels.reloadRefusedReason = "missingPasswordParam"; + } else { + request.wheels.reloadRefusedReason = "refused"; + } + } } if (local.reloadAuthorized) { application.wo.$debugPoint("total,reload"); @@ -384,7 +435,7 @@ component output="false" { && StructKeyExists(application.wo, "$restoreTestRunnerApplicationScope") ) { application.wo.$restoreTestRunnerApplicationScope(); - application.wo.$include(template = "../../#application.wheels.eventPath#/onabort.cfm"); + application.wo.$include(template = "#application.wheels.eventPath#/onabort.cfm"); } return true; } diff --git a/cli/lucli/templates/app/tests/runner.cfm b/cli/lucli/templates/app/tests/runner.cfm index 4155eb08ed..11f3ce60e8 100644 --- a/cli/lucli/templates/app/tests/runner.cfm +++ b/cli/lucli/templates/app/tests/runner.cfm @@ -12,5 +12,10 @@ Keep the include below as the last line (or replicate its body inline) — the framework runner is what produces the JSON / HTML output the rest of the system expects. + + The include path is resolved through $resolveSubpathInclude so it + works both at the web root and under a URL subpath / CommandBox + multi-subfolder install, where a bare `/wheels/...` mapping does not + resolve (issue #3251). ---> - + diff --git a/cli/lucli/templates/auth/api-token-methods.txt b/cli/lucli/templates/auth/api-token-methods.txt new file mode 100644 index 0000000000..4adee67967 --- /dev/null +++ b/cli/lucli/templates/auth/api-token-methods.txt @@ -0,0 +1,19 @@ + + /** + * Issue a new API token, replacing any previous one. Returns the + * plaintext token exactly once; only its SHA-256 digest is stored. + */ + public string function generateApiToken() { + var token = newSecureToken(); + this.apiTokenDigest = LCase(Hash(token, "SHA-256")); + this.save(validate=false, callbacks=false); + return token; + } + + /** + * Revoke the current API token. + */ + public void function revokeApiToken() { + this.apiTokenDigest = ""; + this.save(validate=false, callbacks=false); + } diff --git a/cli/lucli/templates/auth/bootstrap-jwt.txt b/cli/lucli/templates/auth/bootstrap-jwt.txt new file mode 100644 index 0000000000..c0bb0b0fdd --- /dev/null +++ b/cli/lucli/templates/auth/bootstrap-jwt.txt @@ -0,0 +1,23 @@ +// wheels:generate-auth:strategy:begin — generated by `wheels generate auth`; re-run with --force to regenerate +// Wire the JWT strategy into the authenticator once per app boot. +// NOTE: `local.` scope, never template-level `var` — this file is included from +// inside a framework function and Adobe ColdFusion rejects top-level `var` at +// compile time (issue 3063), turning every request into an HTTP 500. +if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { + local.auth = application.wo.service("authenticator"); + if (!local.auth.hasStrategy("jwt")) { + // Fail loudly at startup rather than issuing brute-forceable tokens: + // HMAC-SHA256 needs a secret of at least 32 bytes (RFC 7518 §3.2). + local.jwtSecret = application.wo.env("WHEELS_JWT_SECRET", ""); + if (Len(local.jwtSecret) < 32) { + throw( + type="Wheels.Auth.JWT.MissingSecret", + message="WHEELS_JWT_SECRET is missing or shorter than 32 bytes.", + extendedInfo="Generate a random secret of at least 32 bytes (e.g. `openssl rand -base64 48`) and set it in .env — never commit it to source control." + ); + } + local.jwtService = new wheels.auth.JwtService(secretKey=local.jwtSecret); + local.auth.registerStrategy(name="jwt", strategy=new wheels.auth.JwtStrategy(jwtService=local.jwtService)); + } +} +// wheels:generate-auth:strategy:end diff --git a/cli/lucli/templates/auth/bootstrap-session.txt b/cli/lucli/templates/auth/bootstrap-session.txt new file mode 100644 index 0000000000..b685670b43 --- /dev/null +++ b/cli/lucli/templates/auth/bootstrap-session.txt @@ -0,0 +1,13 @@ +// wheels:generate-auth:strategy:begin — generated by `wheels generate auth`; re-run with --force to regenerate +// Wire the session strategy into the authenticator once per app boot. +// registerStrategy() replaces same-name entries, so warm reloads can't stack duplicates. +// NOTE: `local.` scope, never template-level `var` — this file is included from +// inside a framework function and Adobe ColdFusion rejects top-level `var` at +// compile time (issue 3063), turning every request into an HTTP 500. +if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { + local.auth = application.wo.service("authenticator"); + if (!local.auth.hasStrategy("session")) { + local.auth.registerStrategy(name="session", strategy=application.wo.service("sessionStrategy")); + } +} +// wheels:generate-auth:strategy:end diff --git a/cli/lucli/templates/auth/bootstrap-token.txt b/cli/lucli/templates/auth/bootstrap-token.txt new file mode 100644 index 0000000000..808e4bb4d2 --- /dev/null +++ b/cli/lucli/templates/auth/bootstrap-token.txt @@ -0,0 +1,25 @@ +// wheels:generate-auth:strategy:begin — generated by `wheels generate auth`; re-run with --force to regenerate +// Wire the bearer-token strategy into the authenticator once per app boot. +// NOTE: `local.` scope, never template-level `var` — this file is included from +// inside a framework function and Adobe ColdFusion rejects top-level `var` at +// compile time (issue 3063), turning every request into an HTTP 500. +if (StructKeyExists(application, "wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { + local.auth = application.wo.service("authenticator"); + if (!local.auth.hasStrategy("token")) { + // Hoist the validator into a variable first — an inline function + // literal as a constructor named argument crashes Adobe ColdFusion. + // (`var` is fine INSIDE the closure body — the rule above only + // forbids it at template top level.) + local.tokenValidator = function(required string token) { + // Look up by the token's SHA-256 digest — the raw token is never stored. + var digest = LCase(Hash(arguments.token, "SHA-256")); + var account = application.wo.model("{{modelName}}").where("apiTokenDigest", digest).first(); + if (IsObject(account)) { + return {id: account.key(), email: account.email}; + } + return false; + }; + local.auth.registerStrategy(name="token", strategy=new wheels.auth.TokenStrategy(validator=local.tokenValidator)); + } +} +// wheels:generate-auth:strategy:end diff --git a/cli/lucli/templates/auth/controller-api-sessions-jwt.txt b/cli/lucli/templates/auth/controller-api-sessions-jwt.txt new file mode 100644 index 0000000000..7ae614d964 --- /dev/null +++ b/cli/lucli/templates/auth/controller-api-sessions-jwt.txt @@ -0,0 +1,72 @@ +/** + * api.Sessions — JWT session controller generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely. To pick up generator improvements + * later, re-run `wheels generate auth --force` on a clean branch and review + * the changes with `git diff`. + * + * POST /api/session exchanges credentials for a signed JWT. Clients send it + * back as `Authorization: Bearer `; the JwtStrategy registered in + * app/events/onapplicationstart.cfm verifies the signature and expiry. + * + * NOTE: JWTs are stateless — there is NO server-side revocation. An issued + * token stays valid until it expires (default 1 hour). If you need instant + * revocation, use `--strategy=token` (database-backed opaque tokens) instead. + * + * The signing secret comes from the WHEELS_JWT_SECRET environment variable + * (at least 32 random bytes; see .env). App startup fails loudly when it is + * missing or too short. + * + * NOTE: each login attempt costs a full PBKDF2 derivation, so throttle + * POST /api/session in production — both to slow credential stuffing and to + * keep the CPU cost bounded. Wheels ships wheels.middleware.RateLimiter: add + * an instance to `set(middleware=[...])` in config/settings.cfm or scope one + * to the /api routes in config/routes.cfm (see the middleware guide). + */ +// Lives in app/controllers/api/, so it must extend the app base controller by +// its full mapping path — a bare extends="Controller" cannot resolve from a +// subfolder and fails to compile at request time. +component extends="app.controllers.Controller" { + + function config() { + // Inherit app-wide defaults from app/controllers/Controller.cfc. + super.config(); + // Bearer-token APIs are not cookie-authenticated, so CSRF does not + // apply — replace the inherited exception mode for this controller. + protectsFromForgery(with="ignore"); + provides("json"); + verifies(only="create", post=true, params="email,password"); + } + + // POST /api/session — verify credentials and mint a JWT + function create() { + var email = LCase(Trim(params.email ?: "")); + // Injection-safe query builder — never interpolate user input into a + // where string. + var {{modelVar}} = model("{{modelName}}").where("email", email).first(); + if (!IsObject({{modelVar}})) { + // Equalize timing for unknown emails: without this dummy PBKDF2 + // derivation, requests for nonexistent accounts return measurably + // faster than failed passwords, leaking which addresses are + // registered despite the uniform error message below. + service("passwordHasher").hash(params.password ?: ""); + } + if (IsObject({{modelVar}}) && {{modelVar}}.authenticate(params.password ?: "")) { + // Startup validated WHEELS_JWT_SECRET (see app/events/onapplicationstart.cfm), + // so construction here cannot fail on a running app. + var jwtService = new wheels.auth.JwtService(secretKey=env("WHEELS_JWT_SECRET")); + var token = jwtService.encode(claims={sub: {{modelVar}}.key(), email: {{modelVar}}.email}); + renderWith(data={token: token, tokenType: "Bearer", expiresIn: 3600}, status=201); + } else { + renderWith(data={error: "Invalid email or password."}, status=401); + } + } + + // DELETE /api/session — documentation endpoint: JWTs can't be revoked server-side + function delete() { + renderWith(data={ + message: "JWTs are stateless and cannot be revoked server-side. Discard the token client-side; it expires on its own." + }); + } + +} diff --git a/cli/lucli/templates/auth/controller-api-sessions-token.txt b/cli/lucli/templates/auth/controller-api-sessions-token.txt new file mode 100644 index 0000000000..bccc11cdbd --- /dev/null +++ b/cli/lucli/templates/auth/controller-api-sessions-token.txt @@ -0,0 +1,77 @@ +/** + * api.Sessions — API token session controller generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely. To pick up generator improvements + * later, re-run `wheels generate auth --force` on a clean branch and review + * the changes with `git diff`. + * + * POST /api/session exchanges credentials for an opaque bearer token. The + * plaintext token is returned exactly once; only its SHA-256 digest is + * stored, so a database leak can't redeem it. Clients send it back as + * `Authorization: Bearer `; the TokenStrategy registered in + * app/events/onapplicationstart.cfm resolves it to the account. + * DELETE /api/session revokes the current token. + * + * NOTE: each login attempt costs a full PBKDF2 derivation, so throttle + * POST /api/session in production — both to slow credential stuffing and to + * keep the CPU cost bounded. Wheels ships wheels.middleware.RateLimiter: add + * an instance to `set(middleware=[...])` in config/settings.cfm or scope one + * to the /api routes in config/routes.cfm (see the middleware guide). + */ +// Lives in app/controllers/api/, so it must extend the app base controller by +// its full mapping path — a bare extends="Controller" cannot resolve from a +// subfolder and fails to compile at request time. +component extends="app.controllers.Controller" { + + function config() { + // Inherit app-wide defaults from app/controllers/Controller.cfc. + super.config(); + // Bearer-token APIs are not cookie-authenticated, so CSRF does not + // apply — replace the inherited exception mode for this controller. + protectsFromForgery(with="ignore"); + provides("json"); + verifies(only="create", post=true, params="email,password"); + } + + // POST /api/session — verify credentials, mint and return a token + function create() { + var email = LCase(Trim(params.email ?: "")); + // Injection-safe query builder — never interpolate user input into a + // where string. + var {{modelVar}} = model("{{modelName}}").where("email", email).first(); + if (!IsObject({{modelVar}})) { + // Equalize timing for unknown emails: without this dummy PBKDF2 + // derivation, requests for nonexistent accounts return measurably + // faster than failed passwords, leaking which addresses are + // registered despite the uniform error message below. + service("passwordHasher").hash(params.password ?: ""); + } + if (IsObject({{modelVar}}) && {{modelVar}}.authenticate(params.password ?: "")) { + var token = {{modelVar}}.generateApiToken(); + renderWith(data={token: token, tokenType: "Bearer"}, status=201); + } else { + renderWith(data={error: "Invalid email or password."}, status=401); + } + } + + // DELETE /api/session — revoke the presented token + function delete() { + // Wheels' request.cgi copy is allowlisted (Global.cfc $cgiScope) and + // does NOT carry http_authorization, so hand the bearer header to the + // strategy explicitly instead of passing the raw request scope. + var headers = GetHttpRequestData(false).headers; + var result = service("authenticator").authenticate({ + cgi: {http_authorization: headers["Authorization"] ?: ""} + }); + if (result.success) { + var {{modelVar}} = model("{{modelName}}").findByKey(result.principal.id); + if (IsObject({{modelVar}})) { + {{modelVar}}.revokeApiToken(); + } + renderWith(data={revoked: true}); + } else { + renderWith(data={error: result.error}, status=result.statusCode); + } + } + +} diff --git a/cli/lucli/templates/auth/controller-passwords.txt b/cli/lucli/templates/auth/controller-passwords.txt new file mode 100644 index 0000000000..5c95f413dc --- /dev/null +++ b/cli/lucli/templates/auth/controller-passwords.txt @@ -0,0 +1,111 @@ +/** + * Passwords — password reset controller generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely. To pick up generator improvements + * later, re-run `wheels generate auth --force` on a clean branch and review + * the changes with `git diff`. + * + * Reset flow: `new`/`create` request a reset link, `edit`/`update` redeem it. + * The single-use token travels as the route key (/passwords/[token]/edit). + * Only the token's SHA-256 digest is stored; it expires after 2 hours and is + * cleared when the password is changed. + * + * NOTE: resetting the password does NOT invalidate sessions that are already + * logged in — the session strategy stores principals in the CFML session + * scope, and there is no server-side session registry to sweep. If you need + * "log out everywhere" on reset (e.g. after a compromise), add your own + * invalidation, such as a sessionVersion column compared in an auth filter. + */ +component extends="Controller" { + + function config() { + // Inherit CSRF protection (and other app-wide defaults) from + // app/controllers/Controller.cfc. + super.config(); + verifies(only="create", post=true, params="email"); + verifies(only="edit,update", params="key"); + } + + // GET /passwords/new — request a reset link + function new() { + } + + // POST /passwords — issue a single-use reset token + function create() { + var email = LCase(Trim(params.email ?: "")); + var {{modelVar}} = model("{{modelName}}").where("email", email).first(); + if (IsObject({{modelVar}})) { + var token = {{modelVar}}.generateResetToken(); + // TODO: deliver the link by email, e.g.: + // sendEmail( + // to={{modelVar}}.email, + // from="no-reply@example.com", + // subject="Reset your password", + // template="/passwords/resetEmail", + // resetUrl=urlFor(route="editPassword", key=token, onlyPath=false) + // ); + } + // Same response whether or not the account exists — don't leak + // which email addresses are registered. + redirectTo(route="login", success="If that email address has an account, a reset link is on its way."); + } + + // GET /passwords/[token]/edit — reset form + function edit() { + // Unscoped so the view can render validation errors for it. + {{modelVar}} = findByResetToken(params.key ?: ""); + if (!IsObject({{modelVar}})) { + redirectTo(route="newPassword", error="That password reset link is invalid or has expired."); + } + } + + // PUT /passwords/[token] — set the new password and burn the token + function update() { + {{modelVar}} = findByResetToken(params.key ?: ""); + if (!IsObject({{modelVar}})) { + redirectTo(route="newPassword", error="That password reset link is invalid or has expired."); + return; + } + {{modelVar}}.password = params.{{modelVar}}.password ?: ""; + {{modelVar}}.passwordConfirmation = params.{{modelVar}}.passwordConfirmation ?: ""; + // Reject a blank password HERE: presence is only validated onCreate and + // the hash callback skips blanks, so without this guard a blank submit + // would burn the token, report success, and leave the OLD password in + // place — the worst outcome for someone resetting a compromised account. + if (!Len({{modelVar}}.password)) { + {{modelVar}}.addError(property="password", message="Password can't be blank."); + renderView(action="edit"); + return; + } + // Burn the token in the same save — it only clears if validation passes. + {{modelVar}}.resetTokenDigest = ""; + {{modelVar}}.resetTokenExpiresAt = ""; + if ({{modelVar}}.save()) { + redirectTo(route="login", success="Your password has been reset. Please log in."); + } else { + renderView(action="edit"); + } + } + + /** + * Look up the account for an unexpired reset token. Lookup is by the + * token's SHA-256 digest, so the raw token never touches the database. + * Returns false when the token is unknown or expired. + */ + private any function findByResetToken(required string token) { + if (!Len(arguments.token)) { + return false; + } + var digest = LCase(Hash(arguments.token, "SHA-256")); + var candidate = model("{{modelName}}").where("resetTokenDigest", digest).first(); + if ( + !IsObject(candidate) + || !IsDate(candidate.resetTokenExpiresAt ?: "") + || DateCompare(candidate.resetTokenExpiresAt, Now()) < 0 + ) { + return false; + } + return candidate; + } + +} diff --git a/cli/lucli/templates/auth/controller-registrations.txt b/cli/lucli/templates/auth/controller-registrations.txt new file mode 100644 index 0000000000..cf65bfb67d --- /dev/null +++ b/cli/lucli/templates/auth/controller-registrations.txt @@ -0,0 +1,36 @@ +/** + * Registrations — sign-up controller generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely. To pick up generator improvements + * later, re-run `wheels generate auth --force` on a clean branch and review + * the changes with `git diff`. + * + * Don't want public sign-up? Delete this controller (and its view/routes), + * or re-run the generator with `--no-registration`. + */ +component extends="Controller" { + + function config() { + // Inherit CSRF protection (and other app-wide defaults) from + // app/controllers/Controller.cfc. + super.config(); + verifies(only="create", post=true, params="{{modelVar}}"); + } + + // GET /register — sign-up form + function new() { + {{modelVar}} = model("{{modelName}}").new(); + } + + // POST /register — create the account and log straight in + function create() { + {{modelVar}} = model("{{modelName}}").new(params.{{modelVar}}); + if ({{modelVar}}.save()) { + service("sessionStrategy").login(principal={id: {{modelVar}}.key(), email: {{modelVar}}.email}); + redirectTo(route="root", success="Welcome!"); + } else { + renderView(action="new"); + } + } + +} diff --git a/cli/lucli/templates/auth/controller-sessions.txt b/cli/lucli/templates/auth/controller-sessions.txt new file mode 100644 index 0000000000..c64e972875 --- /dev/null +++ b/cli/lucli/templates/auth/controller-sessions.txt @@ -0,0 +1,61 @@ +/** + * Sessions — login/logout controller generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely. To pick up generator improvements + * later, re-run `wheels generate auth --force` on a clean branch and review + * the changes with `git diff`. + * + * Logging in is modeled as creating a "session" resource: GET /login shows + * the form, POST /login creates the session, DELETE /logout destroys it. + * Use `buttonTo(route="logout", method="delete", text="Log out")` in your + * layout for the logout control (links can't issue DELETE). + * + * NOTE: each login attempt costs a full PBKDF2 derivation, so throttle + * POST /login in production — both to slow credential stuffing and to keep + * the CPU cost bounded. Wheels ships wheels.middleware.RateLimiter: add an + * instance to `set(middleware=[...])` in config/settings.cfm or scope one to + * the login route in config/routes.cfm (see the middleware guide). + */ +component extends="Controller" { + + function config() { + // Inherit CSRF protection (and other app-wide defaults) from + // app/controllers/Controller.cfc. Skipping super.config() would + // silently drop protectsFromForgery() from the login form. + super.config(); + verifies(only="create", post=true, params="email,password"); + } + + // GET /login — login form + function new() { + } + + // POST /login — verify credentials and establish the session + function create() { + var email = LCase(Trim(params.email ?: "")); + // Injection-safe query builder — never interpolate user input into a + // where string (a quote in the email would rewrite the SQL). + var {{modelVar}} = model("{{modelName}}").where("email", email).first(); + if (!IsObject({{modelVar}})) { + // Equalize timing for unknown emails: without this dummy PBKDF2 + // derivation, requests for nonexistent accounts return measurably + // faster than failed passwords, leaking which addresses are + // registered despite the uniform error message below. + service("passwordHasher").hash(params.password ?: ""); + } + if (IsObject({{modelVar}}) && {{modelVar}}.authenticate(params.password ?: "")) { + service("sessionStrategy").login(principal={id: {{modelVar}}.key(), email: {{modelVar}}.email}); + redirectTo(route="root", success="Welcome back."); + } else { + flashInsert(error="Invalid email or password."); + renderView(action="new"); + } + } + + // DELETE /logout — destroy the session + function delete() { + service("sessionStrategy").logout(); + redirectTo(route="login", success="You have been logged out."); + } + +} diff --git a/cli/lucli/templates/auth/migration.txt b/cli/lucli/templates/auth/migration.txt new file mode 100644 index 0000000000..199e2e13dc --- /dev/null +++ b/cli/lucli/templates/auth/migration.txt @@ -0,0 +1,52 @@ +/** + * Migration: create_{{tableName}}_table — generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely before applying. Passwords are + * stored as PBKDF2 digests; reset tokens as SHA-256 digests. + */ +component extends="wheels.migrator.Migration" hint="create {{tableName}} table for authentication" { + + function up() { + var state = {}; + transaction { + try { + t = createTable(name="{{tableName}}"); + t.string(columnNames="email", allowNull=false, limit=255); + t.string(columnNames="passwordDigest", allowNull=false, limit=500); + t.string(columnNames="resetTokenDigest", allowNull=true, limit=64); + t.datetime(columnNames="resetTokenExpiresAt", allowNull=true); +{{apiTokenColumn}} t.timestamps(); + t.create(); + addIndex(table="{{tableName}}", columnNames="email", unique=true); + } catch (any e) { + state.exception = e; + } + + if (StructKeyExists(state, "exception")) { + transaction action="rollback"; + Throw(errorCode="1", detail=state.exception.detail, message=state.exception.message, type="any"); + } else { + transaction action="commit"; + } + } + } + + function down() { + var state = {}; + transaction { + try { + dropTable("{{tableName}}"); + } catch (any e) { + state.exception = e; + } + + if (StructKeyExists(state, "exception")) { + transaction action="rollback"; + Throw(errorCode="1", detail=state.exception.detail, message=state.exception.message, type="any"); + } else { + transaction action="commit"; + } + } + } + +} diff --git a/cli/lucli/templates/auth/model.txt b/cli/lucli/templates/auth/model.txt new file mode 100644 index 0000000000..8e8e1acb1a --- /dev/null +++ b/cli/lucli/templates/auth/model.txt @@ -0,0 +1,100 @@ +/** + * {{modelName}} — authentication model generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — edit it freely. To pick up generator improvements + * later, re-run `wheels generate auth --force` on a clean branch and review + * the changes with `git diff`. + * + * Passwords are hashed with PBKDF2-HMAC-SHA256 via the framework's + * `passwordHasher` service (registered in config/services.cfm). The plaintext + * `password` property is transient: it is validated, hashed into + * `passwordDigest` by the beforeSave callback, then scrubbed. + */ +component extends="Model" { + + function config() { + // Never allow these to be set by mass assignment (params structs). + protectedProperties("passwordDigest,resetTokenDigest,resetTokenExpiresAt{{protectedApiToken}}"); + + // passwordDigest is populated by the beforeSave callback AFTER validation + // runs, so Wheels' automatic NOT-NULL presence validation (added for + // allowNull=false columns) would reject every new record before the hash + // exists. Presence stays guaranteed: validatesPresenceOf(password) on + // create feeds the hashing callback, and the database NOT NULL + // constraint is the backstop. + property(name="passwordDigest", automaticValidations=false); + + // Validations. The transient `password` property is only validated when + // present, so updates that don't touch the password pass untouched. + validatesPresenceOf(property="email"); + validatesUniquenessOf(property="email"); + validatesFormatOf(property="email", regEx="^[\w\.\-\+]+@[\w\.\-]+\.\w+$"); + validatesPresenceOf(property="password", when="onCreate"); + validatesLengthOf(property="password", minimum=12, allowBlank=true); + validatesConfirmationOf(property="password"); + + // Normalize before validating, hash after validating. + beforeValidation("normalizeEmail"); + beforeSave("hashPasswordProperty"); + } + + /** + * Verify a plaintext password against the stored PBKDF2 digest. + * Transparently re-hashes after a successful verify when the stored + * work factor is below the hasher's current configuration. + */ + public boolean function authenticate(required string password) { + if (!Len(this.passwordDigest ?: "")) { + return false; + } + var hasher = service("passwordHasher"); + if (!hasher.verify(password=arguments.password, hash=this.passwordDigest)) { + return false; + } + if (hasher.needsRehash(this.passwordDigest)) { + this.passwordDigest = hasher.hash(arguments.password); + this.save(validate=false, callbacks=false); + } + return true; + } + + /** + * Issue a single-use password reset token, valid for 2 hours. + * Returns the plaintext token for delivery (e.g. by email); only its + * SHA-256 digest is stored, so a database leak can't redeem it. + */ + public string function generateResetToken() { + var token = newSecureToken(); + this.resetTokenDigest = LCase(Hash(token, "SHA-256")); + this.resetTokenExpiresAt = DateAdd("h", 2, Now()); + this.save(validate=false, callbacks=false); + return token; + } +{{apiTokenMethods}} + // ── Callbacks ───────────────────────────────────────────── + + private function normalizeEmail() { + if (StructKeyExists(this, "email") && IsSimpleValue(this.email)) { + this.email = LCase(Trim(this.email)); + } + } + + private function hashPasswordProperty() { + if (StructKeyExists(this, "password") && Len(this.password)) { + this.passwordDigest = service("passwordHasher").hash(this.password); + // Scrub the plaintext so it never persists or leaks in dumps. + StructDelete(this, "password"); + StructDelete(this, "passwordConfirmation"); + } + } + + /** + * 256-bit cryptographically secure random token, hex-encoded. + */ + private string function newSecureToken() { + var bytes = BinaryDecode(RepeatString("00", 32), "hex"); + CreateObject("java", "java.security.SecureRandom").init().nextBytes(bytes); + return LCase(BinaryEncode(bytes, "hex")); + } + +} diff --git a/cli/lucli/templates/auth/routes-api.txt b/cli/lucli/templates/auth/routes-api.txt new file mode 100644 index 0000000000..03168452ea --- /dev/null +++ b/cli/lucli/templates/auth/routes-api.txt @@ -0,0 +1,6 @@ +// wheels:generate-auth:routes:begin — generated by `wheels generate auth`; re-run with --force to regenerate +.namespace("api") + .post(name="session", pattern="/session", to="sessions##create") + .delete(name="logout", pattern="/session", to="sessions##delete") +.end() +// wheels:generate-auth:routes:end diff --git a/cli/lucli/templates/auth/routes-registration.txt b/cli/lucli/templates/auth/routes-registration.txt new file mode 100644 index 0000000000..6ee0f26567 --- /dev/null +++ b/cli/lucli/templates/auth/routes-registration.txt @@ -0,0 +1,2 @@ +.get(name="register", pattern="/register", to="registrations##new") +.post(name="registrations", pattern="/register", to="registrations##create") diff --git a/cli/lucli/templates/auth/routes-session.txt b/cli/lucli/templates/auth/routes-session.txt new file mode 100644 index 0000000000..c2ae036b32 --- /dev/null +++ b/cli/lucli/templates/auth/routes-session.txt @@ -0,0 +1,6 @@ +// wheels:generate-auth:routes:begin — generated by `wheels generate auth`; re-run with --force to regenerate +.get(name="login", pattern="/login", to="sessions##new") +.post(name="session", pattern="/login", to="sessions##create") +.delete(name="logout", pattern="/logout", to="sessions##delete") +{{registrationRoutes}}.resources(name="passwords", only="new,create,edit,update") +// wheels:generate-auth:routes:end diff --git a/cli/lucli/templates/auth/services-api.txt b/cli/lucli/templates/auth/services-api.txt new file mode 100644 index 0000000000..2612b470a2 --- /dev/null +++ b/cli/lucli/templates/auth/services-api.txt @@ -0,0 +1,8 @@ +// wheels:generate-auth:services:begin — generated by `wheels generate auth`; re-run with --force to regenerate +// PBKDF2 password hashing + the strategy-registry authenticator. +// The {{strategy}} strategy itself takes constructor arguments, so it is +// built and registered in app/events/onapplicationstart.cfm. +local.di = injector(); +local.di.map("passwordHasher").to("wheels.auth.PasswordHasher").asSingleton(); +local.di.map("authenticator").to("wheels.auth.Authenticator").asSingleton(); +// wheels:generate-auth:services:end diff --git a/cli/lucli/templates/auth/services-session.txt b/cli/lucli/templates/auth/services-session.txt new file mode 100644 index 0000000000..9201df76b9 --- /dev/null +++ b/cli/lucli/templates/auth/services-session.txt @@ -0,0 +1,8 @@ +// wheels:generate-auth:services:begin — generated by `wheels generate auth`; re-run with --force to regenerate +// PBKDF2 password hashing + the strategy-registry authenticator + session strategy. +// All singletons: one instance per application lifetime. +local.di = injector(); +local.di.map("passwordHasher").to("wheels.auth.PasswordHasher").asSingleton(); +local.di.map("authenticator").to("wheels.auth.Authenticator").asSingleton(); +local.di.map("sessionStrategy").to("wheels.auth.SessionStrategy").asSingleton(); +// wheels:generate-auth:services:end diff --git a/cli/lucli/templates/auth/spec-api-sessions.txt b/cli/lucli/templates/auth/spec-api-sessions.txt new file mode 100644 index 0000000000..89ea720d3d --- /dev/null +++ b/cli/lucli/templates/auth/spec-api-sessions.txt @@ -0,0 +1,31 @@ +/** + * api.Sessions controller spec — generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — extend it as your API auth flow grows. Run with + * `wheels test`. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("api.Sessions controller", () => { + + it("returns 401 for invalid credentials", () => { + var result = processRequest( + params = { + route: "apiSession", + format: "json", + email: "auth-spec-nobody@example.com", + password: "not-the-password-1" + }, + method = "post", + returnAs = "struct" + ); + expect(result.status).toBe(401); + }); + + }); + + } + +} diff --git a/cli/lucli/templates/auth/spec-model.txt b/cli/lucli/templates/auth/spec-model.txt new file mode 100644 index 0000000000..b156e826bf --- /dev/null +++ b/cli/lucli/templates/auth/spec-model.txt @@ -0,0 +1,72 @@ +/** + * {{modelName}} authentication spec — generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — extend it as your auth rules grow. Run with + * `wheels test` (requires the create_{{tableName}} migration applied to the + * test database). + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("{{modelName}} authentication", () => { + + afterEach(() => { + model("{{modelName}}").deleteAll(where="email LIKE 'auth-spec-%'", instantiate=false, softDelete=false); + }); + + it("requires email and password on create", () => { + var account = model("{{modelName}}").new(); + expect(account.valid()).toBeFalse(); + }); + + it("rejects a password shorter than 12 characters", () => { + var account = model("{{modelName}}").new({ + email: "auth-spec-short@example.com", + password: "too-short", + passwordConfirmation: "too-short" + }); + expect(account.valid()).toBeFalse(); + }); + + it("rejects a mismatched password confirmation", () => { + var account = model("{{modelName}}").new({ + email: "auth-spec-mismatch@example.com", + password: "a-long-password-123", + passwordConfirmation: "a-different-password-123" + }); + expect(account.valid()).toBeFalse(); + }); + + it("hashes the password into passwordDigest and authenticates round-trip", () => { + var account = model("{{modelName}}").create({ + email: "auth-spec-roundtrip@example.com", + password: "a-long-password-123", + passwordConfirmation: "a-long-password-123" + }); + expect(account.hasErrors()).toBeFalse(); + expect(Len(account.passwordDigest ?: "")).toBeGT(0); + expect(StructKeyExists(account, "password")).toBeFalse(); + expect(account.authenticate("a-long-password-123")).toBeTrue(); + expect(account.authenticate("not-the-password-1")).toBeFalse(); + }); + + it("enforces email uniqueness", () => { + model("{{modelName}}").create({ + email: "auth-spec-unique@example.com", + password: "a-long-password-123", + passwordConfirmation: "a-long-password-123" + }); + var duplicate = model("{{modelName}}").new({ + email: "auth-spec-unique@example.com", + password: "a-long-password-123", + passwordConfirmation: "a-long-password-123" + }); + expect(duplicate.valid()).toBeFalse(); + }); + + }); + + } + +} diff --git a/cli/lucli/templates/auth/spec-sessions-controller.txt b/cli/lucli/templates/auth/spec-sessions-controller.txt new file mode 100644 index 0000000000..7f339f2130 --- /dev/null +++ b/cli/lucli/templates/auth/spec-sessions-controller.txt @@ -0,0 +1,51 @@ +/** + * Sessions controller spec — generated by `wheels generate auth` (Wheels CLI {{cliVersion}}) on {{generatedDate}}. + * + * This is code you own — extend it as your login flow grows. Run with + * `wheels test`. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("Sessions controller", () => { + + beforeEach(() => { + // The create action is CSRF-protected (super.config() inherits + // protectsFromForgery from the base controller) and verified + // as POST-only, so simulate both. + variables.$originalMethod = request.cgi.request_method; + request.cgi.request_method = "POST"; + variables.csrfToken = CsrfGenerateToken(); + }); + + afterEach(() => { + request.cgi.request_method = variables.$originalMethod; + }); + + it("renders the login form", () => { + request.cgi.request_method = "GET"; + var result = processRequest(params={route: "login"}, method="get", returnAs="struct"); + expect(result.status).toBe(200); + }); + + it("re-renders the form with an error for invalid credentials", () => { + var loginParams = { + controller: "sessions", + action: "create", + email: "auth-spec-nobody@example.com", + password: "not-the-password-1", + authenticityToken: variables.csrfToken + }; + // The action comes from the params handed to controller() — + // processAction()'s only parameter is includeFilters. + var sessionsController = application.wo.controller("sessions", loginParams); + sessionsController.processAction(); + expect(sessionsController.response()).toInclude("Invalid email or password"); + }); + + }); + + } + +} diff --git a/cli/lucli/templates/auth/view-passwords-edit.txt b/cli/lucli/templates/auth/view-passwords-edit.txt new file mode 100644 index 0000000000..59d06d85a6 --- /dev/null +++ b/cli/lucli/templates/auth/view-passwords-edit.txt @@ -0,0 +1,25 @@ + + + + +

Choose a new password

+ +#flashMessages()# + + #errorMessagesFor("{{modelVar}}")# + + +#startFormTag(route="password", method="put", key=params.key)# +
+ #passwordFieldTag(name="{{modelVar}}[password]", label="New password (12 characters minimum)")# +
+
+ #passwordFieldTag(name="{{modelVar}}[passwordConfirmation]", label="Confirm new password")# +
+
+ #submitTag(value="Reset password")# +
+#endFormTag()# + +
diff --git a/cli/lucli/templates/auth/view-passwords-new.txt b/cli/lucli/templates/auth/view-passwords-new.txt new file mode 100644 index 0000000000..887d9f2537 --- /dev/null +++ b/cli/lucli/templates/auth/view-passwords-new.txt @@ -0,0 +1,23 @@ + + + + +

Forgot your password?

+ +#flashMessages()# + +

Enter your email address and we'll send you a link to reset it.

+ +#startFormTag(route="passwords")# +
+ #emailFieldTag(name="email", label="Email", value=params.email)# +
+
+ #submitTag(value="Send reset link")# +
+#endFormTag()# + +

#linkTo(route="login", text="Back to log in")#

+ +
diff --git a/cli/lucli/templates/auth/view-registrations-new.txt b/cli/lucli/templates/auth/view-registrations-new.txt new file mode 100644 index 0000000000..5931f750b6 --- /dev/null +++ b/cli/lucli/templates/auth/view-registrations-new.txt @@ -0,0 +1,28 @@ + + + + +

Create your account

+ +#flashMessages()# +#errorMessagesFor("{{modelVar}}")# + +#startFormTag(route="registrations")# +
+ #emailField(objectName="{{modelVar}}", property="email", label="Email")# +
+
+ #passwordField(objectName="{{modelVar}}", property="password", label="Password (12 characters minimum)")# +
+
+ #passwordField(objectName="{{modelVar}}", property="passwordConfirmation", label="Confirm password")# +
+
+ #submitTag(value="Sign up")# +
+#endFormTag()# + +

Already have an account? #linkTo(route="login", text="Log in")#

+ +
diff --git a/cli/lucli/templates/auth/view-sessions-new.txt b/cli/lucli/templates/auth/view-sessions-new.txt new file mode 100644 index 0000000000..0b003bfb12 --- /dev/null +++ b/cli/lucli/templates/auth/view-sessions-new.txt @@ -0,0 +1,26 @@ + + + + +

Log in

+ +#flashMessages()# + +#startFormTag(route="session")# +
+ #emailFieldTag(name="email", label="Email", value=params.email)# +
+
+ #passwordFieldTag(name="password", label="Password")# +
+
+ #submitTag(value="Log in")# +
+#endFormTag()# + +

+ #linkTo(route="newPassword", text="Forgot your password?")#{{registrationLink}} +

+ +
diff --git a/cli/lucli/tests/specs/commands/TestCommandSpec.cfc b/cli/lucli/tests/specs/commands/TestCommandSpec.cfc index 809dd78e70..5ba3107a05 100644 --- a/cli/lucli/tests/specs/commands/TestCommandSpec.cfc +++ b/cli/lucli/tests/specs/commands/TestCommandSpec.cfc @@ -110,6 +110,33 @@ component extends="wheels.wheelstest.system.BaseSpec" { }); + // Issue #3352: the shared HTTP helper reads for 120 seconds, which is right for the + // request/response bridge commands but is a hard ceiling on how big a suite `wheels + // test` can run. Past roughly 140 seconds the run fails with `Read timed out` and NO + // result document — not a failure report, a crashed runner — and the threshold moves + // with machine speed, so a suite can pass locally and fail in CI. + describe("$resolveTestTimeout", () => { + + it("defaults to 900 seconds when nothing is supplied", () => { + // generous enough that a multi-minute suite completes, which is the ask + expect(mod.$resolveTestTimeout()).toBe(900); + expect(mod.$resolveTestTimeout("")).toBe(900); + expect(mod.$resolveTestTimeout(" ")).toBe(900); + }); + + it("honours an explicit --timeout", () => { + expect(mod.$resolveTestTimeout("1800")).toBe(1800); + expect(mod.$resolveTestTimeout(" 45 ")).toBe(45); + }); + + it("falls back to the default rather than throwing on junk input", () => { + // a mistyped timeout must not be the thing that stops a test run + expect(mod.$resolveTestTimeout("soon")).toBe(900); + expect(mod.$resolveTestTimeout("0")).toBe(900); + expect(mod.$resolveTestTimeout("-30")).toBe(900); + }); + }); + describe("$normalizeTestFilter (app mode)", () => { it("returns empty string for empty input", () => { diff --git a/cli/lucli/tests/specs/services/CodeGenSpec.cfc b/cli/lucli/tests/specs/services/CodeGenSpec.cfc index 6b9370eb6f..504da638a7 100644 --- a/cli/lucli/tests/specs/services/CodeGenSpec.cfc +++ b/cli/lucli/tests/specs/services/CodeGenSpec.cfc @@ -267,6 +267,60 @@ component extends="wheels.wheelstest.system.BaseSpec" { }); + describe("generatePolicy()", () => { + + it("creates the policy and the base Policy.cfc stub on first run", () => { + var result = codegen.generatePolicy(name = "Gadget"); + expect(result.success).toBeTrue(); + expect(fileExists(tempRoot & "/app/policies/GadgetPolicy.cfc")).toBeTrue(); + expect(fileExists(tempRoot & "/app/policies/Policy.cfc")).toBeTrue(); + expect(result.baseCreated).toBeTrue(); + }); + + it("policy extends Policy and declares every standard action denying", () => { + codegen.generatePolicy(name = "Widget", force = true); + var content = fileRead(tempRoot & "/app/policies/WidgetPolicy.cfc"); + expect(content).toInclude('extends="Policy"'); + for (var actionName in ["index", "show", "new", "create", "edit", "update", "delete"]) { + expect(content).toInclude("function #actionName#("); + } + expect(content).toInclude("return false;"); + }); + + it("base stub extends wheels.Policy", () => { + codegen.generatePolicy(name = "Sprocket", force = true); + var content = fileRead(tempRoot & "/app/policies/Policy.cfc"); + expect(content).toInclude('extends="wheels.Policy"'); + }); + + it("normalizes a name already carrying the Policy suffix", () => { + var result = codegen.generatePolicy(name = "ArticlePolicy", force = true); + expect(result.success).toBeTrue(); + expect(fileExists(tempRoot & "/app/policies/ArticlePolicy.cfc")).toBeTrue(); + expect(fileExists(tempRoot & "/app/policies/ArticlePolicyPolicy.cfc")).toBeFalse(); + }); + + it("refuses to overwrite an existing policy without force", () => { + codegen.generatePolicy(name = "Doohickey", force = true); + var path = tempRoot & "/app/policies/DoohickeyPolicy.cfc"; + fileWrite(path, "// SENTINEL"); + var result = codegen.generatePolicy(name = "Doohickey"); + expect(result.success).toBeFalse(); + expect(fileRead(path)).toInclude("SENTINEL"); + }); + + it("never overwrites an existing base Policy.cfc stub", () => { + codegen.generatePolicy(name = "Flange", force = true); + var basePath = tempRoot & "/app/policies/Policy.cfc"; + fileWrite(basePath, "// BASE SENTINEL"); + var result = codegen.generatePolicy(name = "Grommet", force = true); + expect(result.success).toBeTrue(); + expect(result.baseCreated).toBeFalse(); + expect(fileRead(basePath)).toInclude("BASE SENTINEL"); + }); + + }); + describe("validateName()", () => { it("rejects empty name", () => { diff --git a/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc b/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc new file mode 100644 index 0000000000..7322d5fceb --- /dev/null +++ b/cli/lucli/tests/specs/services/GenerateAuthSpec.cfc @@ -0,0 +1,559 @@ +/** + * Tests `wheels generate auth` (issue #3155) — the session/token/jwt + * authentication scaffold built on the wheels.auth primitives. + * + * Service-level coverage runs Scaffold.generateAuth() directly against + * isolated temp projects (one per strategy fixture). A small Module-level + * describe verifies the generate() dispatch reaches generateAuth. + */ +component extends="wheels.wheelstest.system.BaseSpec" { + + function beforeAll() { + variables.testHelper = new cli.lucli.tests.TestHelper(); + variables.moduleRoot = expandPath("/cli/lucli/"); + variables.helpers = new cli.lucli.services.Helpers(); + + // One temp project per strategy fixture, generated once up front. + variables.fixtures = {}; + variables.fixtures.session = $makeFixture({}); + variables.fixtures.noReg = $makeFixture({registration: false}); + variables.fixtures.token = $makeFixture({strategy: "token"}); + variables.fixtures.jwt = $makeFixture({strategy: "jwt"}); + + // Module-level dispatch fixture + variables.dispatchRoot = testHelper.scaffoldTempProject(expandPath("/")); + directoryCreate(variables.dispatchRoot & "/vendor/wheels", true, true); + variables.mod = new cli.lucli.Module(cwd = variables.dispatchRoot); + } + + function afterAll() { + for (var key in variables.fixtures) { + testHelper.cleanupTempProject(variables.fixtures[key].root); + } + testHelper.cleanupTempProject(variables.dispatchRoot); + } + + // ── Fixture helpers ───────────────────────────────────────── + + private struct function $makeFixture(required struct options) { + var root = testHelper.scaffoldTempProject(expandPath("/")); + var scaffold = $newScaffold(root); + var args = duplicate(arguments.options); + args.cliVersion = "test-version"; + var result = scaffold.generateAuth(argumentCollection = args); + return {root: root, scaffold: scaffold, result: result}; + } + + private any function $newScaffold(required string root) { + var templates = new cli.lucli.services.Templates( + helpers = variables.helpers, + projectRoot = arguments.root, + moduleRoot = variables.moduleRoot + ); + var codegen = new cli.lucli.services.CodeGen( + templateService = templates, + helpers = variables.helpers, + projectRoot = arguments.root + ); + return new cli.lucli.services.Scaffold( + codeGenService = codegen, + helpers = variables.helpers, + projectRoot = arguments.root, + moduleRoot = variables.moduleRoot + ); + } + + /** + * Strip CFML line, block, and tag comments so content assertions never + * match commented-out code (anti-pattern ##14). + */ + private string function $stripComments(required string source) { + var result = arguments.source; + result = reReplace(result, "", "", "all"); + result = reReplace(result, "/\*[\s\S]*?\*/", "", "all"); + result = reReplace(result, "//[^\r\n]*", "", "all"); + return result; + } + + private string function $strippedFile(required string path) { + return $stripComments(fileRead(arguments.path)); + } + + private numeric function $countOccurrences(required string haystack, required string needle) { + if (!len(arguments.needle)) return 0; + return (len(arguments.haystack) - len(replace(arguments.haystack, arguments.needle, "", "all"))) / len(arguments.needle); + } + + function run() { + + describe("generateAuth() — session strategy (default)", () => { + + it("succeeds and reports generated files", () => { + expect(fixtures.session.result.success).toBeTrue(); + expect(arrayLen(fixtures.session.result.generated)).toBeGTE(10); + }); + + it("emits the full session file set", () => { + var root = fixtures.session.root; + expect(fileExists(root & "/app/models/User.cfc")).toBeTrue(); + expect(fileExists(root & "/app/controllers/Sessions.cfc")).toBeTrue(); + expect(fileExists(root & "/app/controllers/Passwords.cfc")).toBeTrue(); + expect(fileExists(root & "/app/controllers/Registrations.cfc")).toBeTrue(); + expect(fileExists(root & "/app/views/sessions/new.cfm")).toBeTrue(); + expect(fileExists(root & "/app/views/registrations/new.cfm")).toBeTrue(); + expect(fileExists(root & "/app/views/passwords/new.cfm")).toBeTrue(); + expect(fileExists(root & "/app/views/passwords/edit.cfm")).toBeTrue(); + expect(fileExists(root & "/tests/specs/models/UserAuthSpec.cfc")).toBeTrue(); + expect(fileExists(root & "/tests/specs/controllers/SessionsControllerSpec.cfc")).toBeTrue(); + expect(fileExists(root & "/config/services.cfm")).toBeTrue(); + }); + + it("emits a create-users migration with digest columns, unique email index, and no api token column", () => { + var files = directoryList(fixtures.session.root & "/app/migrator/migrations", false, "name", "*_create_users_table.cfc"); + expect(arrayLen(files)).toBe(1); + var content = fileRead(fixtures.session.root & "/app/migrator/migrations/" & files[1]); + expect(content).toInclude('t.string(columnNames="email"'); + expect(content).toInclude('t.string(columnNames="passwordDigest"'); + expect(content).toInclude('t.string(columnNames="resetTokenDigest"'); + expect(content).toInclude('t.datetime(columnNames="resetTokenExpiresAt"'); + expect(content).toInclude("t.timestamps();"); + expect(content).toInclude('addIndex(table="users", columnNames="email", unique=true)'); + expect(content).notToInclude("apiTokenDigest"); + }); + + it("injects the marked auth route block before the wildcard route", () => { + var content = fileRead(fixtures.session.root & "/config/routes.cfm"); + expect(content).toInclude("wheels:generate-auth:routes:begin"); + expect(content).toInclude("wheels:generate-auth:routes:end"); + expect(content).toInclude('.get(name="login"'); + expect(content).toInclude('.delete(name="logout"'); + expect(content).toInclude('.resources(name="passwords", only="new,create,edit,update")'); + expect(content).toInclude('.get(name="register"'); + expect(find("wheels:generate-auth:routes:begin", content)).toBeLT(find(".wildcard()", content)); + }); + + it("wires passwordHasher, authenticator, and sessionStrategy singletons in config/services.cfm", () => { + var content = fileRead(fixtures.session.root & "/config/services.cfm"); + expect(content).toInclude("wheels:generate-auth:services:begin"); + expect(content).toInclude('map("passwordHasher").to("wheels.auth.PasswordHasher").asSingleton()'); + expect(content).toInclude('map("authenticator").to("wheels.auth.Authenticator").asSingleton()'); + expect(content).toInclude('map("sessionStrategy").to("wheels.auth.SessionStrategy").asSingleton()'); + }); + + it("registers the session strategy in app/events/onapplicationstart.cfm", () => { + var content = fileRead(fixtures.session.root & "/app/events/onapplicationstart.cfm"); + expect(content).toInclude("wheels:generate-auth:strategy:begin"); + expect(content).toInclude('registerStrategy(name="session"'); + }); + + it("calls super.config() first in every generated controller (##2960)", () => { + for (var name in ["Sessions", "Passwords", "Registrations"]) { + var stripped = $strippedFile(fixtures.session.root & "/app/controllers/" & name & ".cfc"); + expect(reFind("function config\(\)\s*\{\s*super\.config\(\);", stripped)).toBeGT( + 0, + name & ".cfc must call super.config() as the first statement of config()" + ); + } + }); + + it("hashes via the passwordHasher service and scrubs the transient password in the model", () => { + var stripped = $strippedFile(fixtures.session.root & "/app/models/User.cfc"); + expect(stripped).toInclude('beforeSave("hashPasswordProperty")'); + expect(stripped).toInclude('service("passwordHasher")'); + expect(stripped).toInclude("function authenticate("); + expect(stripped).toInclude("needsRehash"); + expect(stripped).toInclude('protectedProperties('); + }); + + it("disables the automatic NOT-NULL presence validation on passwordDigest", () => { + // passwordDigest is only populated by the beforeSave callback, + // which runs AFTER validation — Wheels' automatic presence + // validation for the allowNull=false column would otherwise + // reject every new record ("Password Digest can't be empty"). + // Verified live: seeding/registration failed until this line + // was added (runtime verification on PR ##3291). + for (var key in ["session", "token", "jwt"]) { + var stripped = $strippedFile(fixtures[key].root & "/app/models/User.cfc"); + expect(stripped).toInclude('property(name="passwordDigest", automaticValidations=false)'); + } + }); + + it("uses the injection-safe query builder rather than interpolated where strings", () => { + var stripped = $strippedFile(fixtures.session.root & "/app/controllers/Sessions.cfc"); + expect(stripped).toInclude('.where("email", email)'); + expect(reFindNoCase("where\s*=\s*""[^""]*##", stripped)).toBe(0); + }); + + it("stamps every generated CFC with the code-you-own header", () => { + for (var rel in ["app/models/User.cfc", "app/controllers/Sessions.cfc", "app/controllers/Passwords.cfc"]) { + var content = fileRead(fixtures.session.root & "/" & rel); + expect(content).toInclude("wheels generate auth"); + expect(content).toInclude("--force"); + expect(content).toInclude("test-version"); + } + }); + + it("uses startFormTag-based forms with cfoutput in every view", () => { + // chr(60)-concat keeps a literal tag out of this source file — + // Lucee's tag scanner crashes the whole bundle otherwise. + var openingOutputTag = chr(60) & "cfoutput" & chr(62); + for (var rel in ["app/views/sessions/new.cfm", "app/views/registrations/new.cfm", "app/views/passwords/new.cfm", "app/views/passwords/edit.cfm"]) { + var content = fileRead(fixtures.session.root & "/" & rel); + expect(content).toInclude("startFormTag("); + expect(content).toInclude("endFormTag()"); + expect(content).toInclude(openingOutputTag); + } + }); + + it("never passes an inline closure as a constructor named argument (Cross-Engine Invariant 5)", () => { + var bootstrap = $stripComments(fileRead(fixtures.session.root & "/app/events/onapplicationstart.cfm")); + expect(reFindNoCase("new\s+wheels\.auth\.[A-Za-z]+\([^)]*=\s*function", bootstrap)).toBe(0); + }); + + it("rejects a blank password on reset instead of burning the token (Passwords##update)", () => { + var stripped = $strippedFile(fixtures.session.root & "/app/controllers/Passwords.cfc"); + // Presence is only validated onCreate and the hash callback + // skips blanks — without this guard a blank submit clears the + // token, reports success, and keeps the old password valid. + expect(stripped).toInclude('addError(property="password"'); + var guardPos = find('addError(property="password"', stripped); + var burnPos = find('resetTokenDigest = ""', stripped); + expect(guardPos).toBeGT(0); + expect(burnPos).toBeGT(0); + expect(guardPos).toBeLT(burnPos, "the blank-password guard must run before the token is cleared"); + }); + + it("equalizes login timing with a dummy derivation when the email is unknown", () => { + var stripped = $strippedFile(fixtures.session.root & "/app/controllers/Sessions.cfc"); + expect(stripped).toInclude('service("passwordHasher").hash('); + }); + + it("emits a controller spec that calls processAction() with no positional action argument", () => { + var stripped = $strippedFile(fixtures.session.root & "/tests/specs/controllers/SessionsControllerSpec.cfc"); + // processAction()'s only parameter is includeFilters — a + // positional "create" would silently disable before-filters. + expect(stripped).toInclude("processAction()"); + expect(stripped).notToInclude('processAction("'); + }); + + }); + + describe("generateAuth() — bootstrap uses local.-scoped variables, never template-level var (##3063)", () => { + + // app/events/onapplicationstart.cfm is $include()d from a framework + // function; Adobe CF rejects top-level `var` in an included template + // at COMPILE time, turning every request into an HTTP 500. `var` + // inside the hoisted closure body is fine and stays. + it("session bootstrap", () => { + var bootstrap = $stripComments(fileRead(fixtures.session.root & "/app/events/onapplicationstart.cfm")); + expect(bootstrap).toInclude("local.auth = "); + expect(bootstrap).notToInclude("var auth"); + }); + + it("token bootstrap", () => { + var bootstrap = $stripComments(fileRead(fixtures.token.root & "/app/events/onapplicationstart.cfm")); + expect(bootstrap).toInclude("local.auth = "); + expect(bootstrap).toInclude("local.tokenValidator = "); + expect(bootstrap).notToInclude("var auth"); + expect(bootstrap).notToInclude("var tokenValidator"); + }); + + it("jwt bootstrap", () => { + var bootstrap = $stripComments(fileRead(fixtures.jwt.root & "/app/events/onapplicationstart.cfm")); + expect(bootstrap).toInclude("local.auth = "); + expect(bootstrap).toInclude("local.jwtSecret = "); + expect(bootstrap).toInclude("local.jwtService = "); + expect(bootstrap).notToInclude("var auth"); + expect(bootstrap).notToInclude("var jwtSecret"); + expect(bootstrap).notToInclude("var jwtService"); + }); + + }); + + describe("generateAuth() — --no-registration", () => { + + it("omits the Registrations controller, its view, and its routes", () => { + var root = fixtures.noReg.root; + expect(fixtures.noReg.result.success).toBeTrue(); + expect(fileExists(root & "/app/controllers/Registrations.cfc")).toBeFalse(); + expect(fileExists(root & "/app/views/registrations/new.cfm")).toBeFalse(); + var routes = fileRead(root & "/config/routes.cfm"); + expect(routes).notToInclude('to="registrations'); + expect(routes).notToInclude('.get(name="register"'); + expect(routes).toInclude('.get(name="login"'); + }); + + it("omits the sign-up link from the login view", () => { + var content = fileRead(fixtures.noReg.root & "/app/views/sessions/new.cfm"); + expect(content).notToInclude('route="register"'); + }); + + }); + + describe("generateAuth() — token strategy", () => { + + it("emits an API sessions controller and no browser views or registrations", () => { + var root = fixtures.token.root; + expect(fixtures.token.result.success).toBeTrue(); + expect(fileExists(root & "/app/controllers/api/Sessions.cfc")).toBeTrue(); + expect(fileExists(root & "/app/controllers/Sessions.cfc")).toBeFalse(); + expect(fileExists(root & "/app/controllers/Registrations.cfc")).toBeFalse(); + expect(fileExists(root & "/app/views/sessions/new.cfm")).toBeFalse(); + expect(fileExists(root & "/tests/specs/controllers/ApiSessionsControllerSpec.cfc")).toBeTrue(); + }); + + it("adds the apiTokenDigest column to the migration", () => { + var files = directoryList(fixtures.token.root & "/app/migrator/migrations", false, "name", "*_create_users_table.cfc"); + expect(arrayLen(files)).toBe(1); + var content = fileRead(fixtures.token.root & "/app/migrator/migrations/" & files[1]); + expect(content).toInclude('t.string(columnNames="apiTokenDigest"'); + }); + + it("stores only the SHA-256 digest and returns the plaintext token once", () => { + var model = $strippedFile(fixtures.token.root & "/app/models/User.cfc"); + expect(model).toInclude("function generateApiToken("); + expect(model).toInclude('Hash(token, "SHA-256")'); + var controller = $strippedFile(fixtures.token.root & "/app/controllers/api/Sessions.cfc"); + expect(reFind("function config\(\)\s*\{\s*super\.config\(\);", controller)).toBeGT(0); + expect(controller).toInclude("renderWith("); + }); + + it("hoists the token validator instead of inlining a closure into the constructor", () => { + var bootstrap = $stripComments(fileRead(fixtures.token.root & "/app/events/onapplicationstart.cfm")); + expect(bootstrap).toInclude("local.tokenValidator = function"); + expect(bootstrap).toInclude("TokenStrategy(validator=local.tokenValidator)"); + expect(reFindNoCase("TokenStrategy\(\s*validator\s*=\s*function", bootstrap)).toBe(0); + }); + + it("equalizes login timing with a dummy derivation when the email is unknown", () => { + var stripped = $strippedFile(fixtures.token.root & "/app/controllers/api/Sessions.cfc"); + expect(stripped).toInclude('service("passwordHasher").hash('); + }); + + it("hands the Authorization header to the authenticator explicitly on revoke", () => { + // request.cgi is allowlisted (Global.cfc $cgiScope) and omits + // http_authorization — passing the raw request scope would 401 + // every revoke. + var stripped = $strippedFile(fixtures.token.root & "/app/controllers/api/Sessions.cfc"); + expect(stripped).toInclude("GetHttpRequestData"); + expect(stripped).toInclude("http_authorization"); + expect(stripped).notToInclude(".authenticate(request)"); + }); + + it("injects api-namespaced session routes", () => { + var routes = fileRead(fixtures.token.root & "/config/routes.cfm"); + expect(routes).toInclude('.namespace("api")'); + expect(routes).toInclude("wheels:generate-auth:routes:begin"); + }); + + it("extends the app base controller by full mapping path (namespaced controller)", () => { + // app/controllers/api/Sessions.cfc lives in a subfolder — a bare + // extends="Controller" cannot resolve from there and fails to + // compile at request time (verified live on PR ##3291). Matches + // the admin generator's namespaced-controller convention. + var stripped = $strippedFile(fixtures.token.root & "/app/controllers/api/Sessions.cfc"); + expect(stripped).toInclude('extends="app.controllers.Controller"'); + }); + + it("notes that the registration flag does not apply", () => { + var notes = arrayToList(fixtures.token.result.skipped, "|"); + expect(notes).toInclude("registration"); + }); + + }); + + describe("generateAuth() — jwt strategy", () => { + + it("emits an API sessions controller that mints JWTs via JwtService", () => { + var root = fixtures.jwt.root; + expect(fixtures.jwt.result.success).toBeTrue(); + expect(fileExists(root & "/app/controllers/api/Sessions.cfc")).toBeTrue(); + var controller = $strippedFile(root & "/app/controllers/api/Sessions.cfc"); + expect(controller).toInclude("wheels.auth.JwtService"); + expect(controller).toInclude("WHEELS_JWT_SECRET"); + expect(reFind("function config\(\)\s*\{\s*super\.config\(\);", controller)).toBeGT(0); + }); + + it("fails loudly at startup when WHEELS_JWT_SECRET is missing or short", () => { + var bootstrap = $stripComments(fileRead(fixtures.jwt.root & "/app/events/onapplicationstart.cfm")); + expect(bootstrap).toInclude("WHEELS_JWT_SECRET"); + expect(bootstrap).toInclude("throw("); + }); + + it("documents that JWTs have no server-side revocation", () => { + var content = fileRead(fixtures.jwt.root & "/app/controllers/api/Sessions.cfc"); + expect(content).toInclude("revocation"); + }); + + it("does not add the apiTokenDigest column", () => { + var files = directoryList(fixtures.jwt.root & "/app/migrator/migrations", false, "name", "*_create_users_table.cfc"); + var content = fileRead(fixtures.jwt.root & "/app/migrator/migrations/" & files[1]); + expect(content).notToInclude("apiTokenDigest"); + }); + + it("equalizes login timing with a dummy derivation when the email is unknown", () => { + var stripped = $strippedFile(fixtures.jwt.root & "/app/controllers/api/Sessions.cfc"); + expect(stripped).toInclude('service("passwordHasher").hash('); + }); + + it("extends the app base controller by full mapping path (namespaced controller)", () => { + var stripped = $strippedFile(fixtures.jwt.root & "/app/controllers/api/Sessions.cfc"); + expect(stripped).toInclude('extends="app.controllers.Controller"'); + }); + + }); + + describe("generateAuth() — force and idempotency", () => { + + it("refuses to overwrite existing files without --force", () => { + var root = fixtures.session.root; + var before = fileRead(root & "/app/models/User.cfc"); + var result = fixtures.session.scaffold.generateAuth(cliVersion = "second-run"); + expect(arrayLen(result.skipped)).toBeGTE(1); + expect(fileRead(root & "/app/models/User.cfc")).toBe(before); + expect(fileRead(root & "/app/models/User.cfc")).notToInclude("second-run"); + }); + + it("re-running without --force does not duplicate the route, service, or strategy blocks", () => { + var root = fixtures.session.root; + expect($countOccurrences(fileRead(root & "/config/routes.cfm"), "wheels:generate-auth:routes:begin")).toBe(1); + expect($countOccurrences(fileRead(root & "/config/services.cfm"), "wheels:generate-auth:services:begin")).toBe(1); + expect($countOccurrences(fileRead(root & "/app/events/onapplicationstart.cfm"), "wheels:generate-auth:strategy:begin")).toBe(1); + }); + + it("--force overwrites files and replaces the injected blocks exactly once", () => { + var root = fixtures.session.root; + var result = fixtures.session.scaffold.generateAuth(force = true, cliVersion = "forced-run"); + expect(result.success).toBeTrue(); + expect(fileRead(root & "/app/models/User.cfc")).toInclude("forced-run"); + expect($countOccurrences(fileRead(root & "/config/routes.cfm"), "wheels:generate-auth:routes:begin")).toBe(1); + expect($countOccurrences(fileRead(root & "/config/services.cfm"), "wheels:generate-auth:services:begin")).toBe(1); + expect($countOccurrences(fileRead(root & "/app/events/onapplicationstart.cfm"), "wheels:generate-auth:strategy:begin")).toBe(1); + }); + + it("rejects an unknown strategy", () => { + expect(() => { + fixtures.session.scaffold.generateAuth(strategy = "basic"); + }).toThrow(); + }); + + }); + + describe("generateAuth() — routes anchor handling", () => { + + it("falls back to the first uncommented .root( when the CLI-Appends-Here marker is missing", () => { + var root = testHelper.scaffoldTempProject(expandPath("/")); + try { + var routesPath = root & "/config/routes.cfm"; + var nl = chr(10); + // No marker; a commented-out .root( example above the real one, + // mirroring the stock app template. + fileWrite( + routesPath, + "// routes" & nl + & "mapper()" & nl + & chr(9) & "// .root(to = ""home####index"", method = ""get"")" & nl + & chr(9) & ".wildcard()" & nl + & chr(9) & ".root(method = ""get"")" & nl + & chr(9) & ".end();" & nl + ); + var result = $newScaffold(root).generateAuth(cliVersion = "test-version"); + expect(result.success).toBeTrue(); + var written = fileRead(routesPath); + expect(written).toInclude("wheels:generate-auth:routes:begin"); + // $findCodePosition skips the commented-out .root( example and + // anchors on the real one. + expect(find("wheels:generate-auth:routes:begin", written)).toBeLT(find('.root(method = "get")', written)); + } finally { + testHelper.cleanupTempProject(root); + } + }); + + it("skips with a manual-insert note instead of injecting dead routes when no anchor exists", () => { + var root = testHelper.scaffoldTempProject(expandPath("/")); + try { + var routesPath = root & "/config/routes.cfm"; + // Hand-edited file: no CLI-Appends-Here and the only .root( is + // commented out. An .end() fallback would have parked the auth + // routes after .wildcard(), where they could never match + // (anti-pattern ##6) — refusing is the only safe move. + var scriptOpen = chr(60) & "cfscript" & chr(62); + var scriptClose = chr(60) & "/cfscript" & chr(62); + var nl = chr(10); + fileWrite( + routesPath, + scriptOpen & nl + & "mapper()" & nl + & chr(9) & "// .root(to = ""home####index"", method = ""get"")" & nl + & chr(9) & ".wildcard()" & nl + & chr(9) & ".end();" & nl + & scriptClose & nl + ); + var result = $newScaffold(root).generateAuth(cliVersion = "test-version"); + expect(result.success).toBeTrue(); + expect(fileRead(routesPath)).notToInclude("wheels:generate-auth:routes:begin"); + expect(arrayToList(result.skipped, "|")).toInclude("add this block manually"); + } finally { + testHelper.cleanupTempProject(root); + } + }); + + it("refuses to regenerate a block whose begin marker has no matching end marker", () => { + var root = testHelper.scaffoldTempProject(expandPath("/")); + try { + var scaffold = $newScaffold(root); + expect(scaffold.generateAuth(cliVersion = "test-version").success).toBeTrue(); + var routesPath = root & "/config/routes.cfm"; + fileWrite(routesPath, replace(fileRead(routesPath), "// wheels:generate-auth:routes:end", "")); + var second = scaffold.generateAuth(force = true, cliVersion = "second-run"); + expect(arrayToList(second.skipped, "|")).toInclude("begin marker without matching end marker"); + // The corrupted block is left untouched for the user to fix. + expect($countOccurrences(fileRead(routesPath), "wheels:generate-auth:routes:begin")).toBe(1); + } finally { + testHelper.cleanupTempProject(root); + } + }); + + }); + + describe("generateAuth() — custom model name", () => { + + it("respects --model for file names, table name, and route wiring", () => { + var root = testHelper.scaffoldTempProject(expandPath("/")); + try { + var scaffold = $newScaffold(root); + var result = scaffold.generateAuth(model = "Member", cliVersion = "test-version"); + expect(result.success).toBeTrue(); + expect(fileExists(root & "/app/models/Member.cfc")).toBeTrue(); + var files = directoryList(root & "/app/migrator/migrations", false, "name", "*_create_members_table.cfc"); + expect(arrayLen(files)).toBe(1); + var sessions = fileRead(root & "/app/controllers/Sessions.cfc"); + expect(sessions).toInclude('model("Member")'); + } finally { + testHelper.cleanupTempProject(root); + } + }); + + }); + + describe("wheels generate auth — Module dispatch", () => { + + it("reaches generateAuth from the generate() switch and writes the scaffold", () => { + // arg1= exercises the structured callerArgs dispatch path — the + // same handoff LuCLI produces for `wheels generate auth`. + mod.generate(arg1 = "auth"); + expect(fileExists(variables.dispatchRoot & "/app/controllers/Sessions.cfc")).toBeTrue(); + expect(fileExists(variables.dispatchRoot & "/app/models/User.cfc")).toBeTrue(); + }); + + it("throws Wheels.InvalidArguments for an unknown strategy", () => { + expect(() => { + mod.generate(arg1 = "auth", strategy = "basic"); + }).toThrow(type = "Wheels.InvalidArguments"); + }); + + }); + + } + +} diff --git a/cli/src/commands/wheels/analyze/code.cfc b/cli/src/commands/wheels/analyze/code.cfc index 9a09a8a030..cd59f95a91 100644 --- a/cli/src/commands/wheels/analyze/code.cfc +++ b/cli/src/commands/wheels/analyze/code.cfc @@ -661,7 +661,7 @@ component extends="../base" { html &= ' diff --git a/cli/src/templates/ConfigRoutes.txt b/cli/src/templates/ConfigRoutes.txt index 4522a53d28..7220374991 100644 --- a/cli/src/templates/ConfigRoutes.txt +++ b/cli/src/templates/ConfigRoutes.txt @@ -2,7 +2,7 @@ // Use this file to add routes to your application and point the root route to a controller action. // Don't forget to issue a reload request (e.g. reload=true) after making changes. - // See https://guides.wheels.dev/v4-0-0-snapshot/handling-requests-with-controllers/routing for more info. + // See https://guides.wheels.dev/v4-0-0/basics/routing/ for more info. mapper() // CLI-Appends-Here diff --git a/cli/src/templates/PolicyBaseContent.txt b/cli/src/templates/PolicyBaseContent.txt new file mode 100644 index 0000000000..10f3acbb5b --- /dev/null +++ b/cli/src/templates/PolicyBaseContent.txt @@ -0,0 +1,12 @@ +/** + * This is the parent policy file that all your policies should extend. + * You can add functions to this file to make them available in all your policies. + * Do not delete this file. + * + * Policies are DEFAULT-DENY: every standard action on the wheels.Policy base + * returns false, so each policy must explicitly override a method to grant it. + */ +component extends="wheels.Policy" { + + +} diff --git a/cli/src/templates/PolicyContent.txt b/cli/src/templates/PolicyContent.txt new file mode 100644 index 0000000000..ac8fb7df59 --- /dev/null +++ b/cli/src/templates/PolicyContent.txt @@ -0,0 +1,67 @@ +|DescriptionComment|/** + * Authorization policy for the {{modelName}} model — answers "may this user + * perform this action on this {{modelName}}?". + * + * Policies are DEFAULT-DENY: every method below denies until you change it. + * `variables.user` holds the current identity (empty string for guests) and + * `variables.record` holds the {{modelName}} being authorized. + * + * Enforce in a controller action: authorize(post); + * Check without throwing (views): can("update", post) + * Narrow an index collection: policyScope(model("{{modelName}}")).findAll() + */ +component extends="Policy" { + + public boolean function index() { + return false; + } + + public boolean function show() { + return false; + } + + public boolean function new() { + return false; + } + + public boolean function create() { + return false; + } + + public boolean function edit() { + return false; + } + + public boolean function update() { + return false; + } + + public boolean function delete() { + return false; + } + + // Examples — adapt and replace the denials above: + // + // Grant to any signed-in user: + // public boolean function index() { + // return IsStruct(variables.user) && !StructIsEmpty(variables.user); + // } + // + // Grant to the record's owner: + // public boolean function update() { + // return IsStruct(variables.user) + // && StructKeyExists(variables.user, "id") + // && variables.user.id == variables.record.userId; + // } + + /** + * Narrows a collection to the records the user may see (used by + * policyScope() for index actions). Inherits "no rows" from the base — + * override to widen, e.g.: + * + * public any function scope(required any collection) { + * return arguments.collection.where("userId", variables.user.id); + * } + */ + +} diff --git a/cli/tests/specs/e2e/ProjectScaffoldTest.cfc b/cli/tests/specs/e2e/ProjectScaffoldTest.cfc index 3e7a54deb8..675b1c1ad6 100644 --- a/cli/tests/specs/e2e/ProjectScaffoldTest.cfc +++ b/cli/tests/specs/e2e/ProjectScaffoldTest.cfc @@ -214,6 +214,16 @@ component extends="testbox.system.BaseSpec" { var content = fileRead(path); expect(content).toInclude("Welcome to testapp"); + // Runtime expressions must survive generation as single-hash + // CFML (## -> # in the fileWrite string), not be evaluated at + // scaffold time. Locks in the escaping shared with Module.cfc. + expect(content).toInclude('##get("version")##'); + expect(content).toInclude('##application.wheels.serverName##'); + expect(content).toInclude(""); + expect(content).toInclude("Next steps"); + expect(content).toInclude("wheels g scaffold"); + expect(content).toInclude("wheels migrate latest"); + expect(content).toInclude("wheels test"); }); it("generates base Controller.cfc in app/controllers/", function() { @@ -325,7 +335,27 @@ component extends="testbox.system.BaseSpec" { fileWrite( arguments.targetDir & "/app/views/main/index.cfm", - '

Welcome to ' & arguments.appName & '

' & nl & '

Your Wheels application is running. Edit this file at app/views/main/index.cfm

' & nl + ( + '' & nl & + '' & nl & + '

Welcome to ' & arguments.appName & '

' & nl & + '

Your Wheels ##get("version")## application is running on ##application.wheels.serverName## with ##application.wheels.dataSourceName## (##get("environment")##).

' & nl & + nl & + '

Next steps

' & nl & + '
    ' & nl & + tab & '
  • wheels g scaffold Post title content:text — generate a model, controller, and views
  • ' & nl & + tab & '
  • wheels migrate latest — build the database schema
  • ' & nl & + tab & '
  • wheels test — run the test suite
  • ' & nl & + '
' & nl & + '

This page lives at app/views/main/index.cfm; routing is in config/routes.cfm.

' & nl & + '
' & nl + ) ); } diff --git a/config/environment.cfm b/config/environment.cfm index b1d7e5e97f..cc8cd3d1a0 100644 --- a/config/environment.cfm +++ b/config/environment.cfm @@ -2,7 +2,7 @@ // Use this file to set the current environment for your application. // You can set it to "development", "testing", "maintenance" or "production". // Don't forget to issue a reload request (e.g. reload=true) after making changes. -// See https://wheels.dev/3.1.0/guides/working-with-wheels/switching-environments for more info. +// See https://guides.wheels.dev/v4-0-0/core-concepts/environments-and-configuration/ for more info. // Below, we have set it to "development" for you since that is convenient when you are building your application. // We recommend that you change this to "production" when you're running your application live. diff --git a/config/routes.cfm b/config/routes.cfm index 193361273b..fd7b7faa36 100755 --- a/config/routes.cfm +++ b/config/routes.cfm @@ -1,7 +1,7 @@ // Use this file to add routes to your application and point the root route to a controller action. // Don't forget to issue a reload request (e.g. reload=true) after making changes. -// See https://wheels.dev/3.1.0/guides/handling-requests-with-controllers/routing for more info. +// See https://guides.wheels.dev/v4-0-0/basics/routing/ for more info. mapper() // CLI-Appends-Here diff --git a/config/settings.cfm b/config/settings.cfm index cf7ecb547f..0b3d81020a 100644 --- a/config/settings.cfm +++ b/config/settings.cfm @@ -3,7 +3,7 @@ Use this file to configure your application. You can also use the environment specific files (e.g. /config/production/settings.cfm) to override settings set here. Don't forget to issue a reload request (e.g. reload=true) after making changes. - See https://wheels.dev/3.1.0/guides/working-with-wheels/configuration-and-defaults for more info. + See https://guides.wheels.dev/v4-0-0/core-concepts/environments-and-configuration/ for more info. */ /* diff --git a/docs/releases/wheels-5-roadmap.md b/docs/releases/wheels-5-roadmap.md new file mode 100644 index 0000000000..0ee826d270 --- /dev/null +++ b/docs/releases/wheels-5-roadmap.md @@ -0,0 +1,268 @@ +# Wheels 5 Roadmap + +**Status:** Roadmap (decided direction, open items flagged explicitly) +**Date:** 2026-06-20 +**Supersedes:** the earlier `wheels-5-roadmap-recommendations.md` draft, which is now folded into this single document. + +This is the one document that describes what Wheels 5 is for, what it will and will not do, and why. It is written to be acted on, not just discussed. Where a decision is genuinely still open it is listed in [§11 Open decisions](#11-open-decisions) with a recommended default; everything else is a committed direction. + +--- + +## 1. The honest starting point + +Wheels 5 planning has to begin with two facts that earlier drafts assumed away. + +**The CFML market is contracting, not growing.** CFML/ColdFusion is niche and maintenance-weighted: it is effectively absent from the Stack Overflow 2025 developer survey and the TIOBE top tier, the expert pool is shrinking (which is why CF salaries hold up — scarcity, not demand), and greenfield CFML projects are increasingly rare. Vendors themselves now position the language around "maintain mission-critical legacy systems." + +The strategic consequence is blunt: **our competitor is not ColdBox. It is attrition** — developers leaving CFML entirely for Rails, Laravel, Node, or Go. Fighting ColdBox for a larger slice of CFML is fighting for a larger slice of a shrinking pie. ColdBox, backed by Ortus and now vertically integrated with the BoxLang runtime (ColdBox 8 ships native BoxLang integration; ~350k installs in 12 months; 720+ modules), will win any feature-for-feature, vendor-funded race. We should not enter that race. + +**Wheels is a small, largely volunteer project.** The active maintainer group is a handful of people, and the community is an order of magnitude smaller than the Ortus/ColdBox ecosystem. Any roadmap that assumes the capacity to build a conformance program, a partnership track, a five-tier CI matrix, and a half-dozen new subsystems in one major version will overcommit and stall. Scope realism is a feature of this roadmap, not an afterthought. + +These two facts set the objective function for Wheels 5: + +> Maximize **retention** (keep existing Wheels apps and teams productive and confident) and minimize **onboarding friction**, while making exactly one credible **forward bet**. Do not chase market share against a vendor-funded competitor in a shrinking market. + +--- + +## 2. Strategic position + +Wheels remains the **elegant, convention-first, Rails-inspired, runtime-neutral full-stack framework for CFML** — small enough that a developer can hold the whole model in their head. + +Three things define the lane, and they are chosen because they are defensible for a small team in a contracting market: + +1. **Runtime neutrality.** Wheels runs on Lucee, Adobe CF, and BoxLang and is beholden to no single vendor. This is the direct, honest counter to ColdBox/BoxLang vertical integration. We cannot out-integrate a vendor that owns the runtime; we can be the framework that never locks you to one. Neutrality is the brand promise — backed by CI, not marketing copy. +2. **Lowest onboarding and migration friction in CFML.** A clean install path, scaffolding that works on a fresh machine, a believable upgrade story, and documentation that matches the code. For a shrinking talent pool, "easy to pick up and cheap to maintain" is worth more than any advanced feature. +3. **One forward bet: AI-legibility.** Niche-language code is increasingly written and maintained by AI agents (a working CFML engine, RustCFML, was authored almost entirely by Claude — the ecosystem is already there). The most defensible single differentiator for Wheels 5 is to be **the most AI-legible CFML framework**: machine-readable routing and request lifecycle, a stable MCP/introspection surface, and generators that emit one canonical idiom an agent can rely on. This is the only theme that simultaneously rides where development is going and lowers maintenance cost for the apps we want to retain. + +What Wheels 5 explicitly is **not**: a ColdBox competitor on enterprise surface area, a BoxLang-first framework, or a platform with many named subsystems. See [§10](#10-what-wheels-5-will-not-do). + +--- + +## 3. Wheels 4 baseline (what already ships) + +This roadmap is a **coherence release**, so it must start from an accurate inventory. The following already exist in Wheels 4 as substantial, non-stub implementations. Wheels 5 work on them is **harden / document / contract**, never "add": + +| Capability | Lives in | +|---|---| +| DI container | [`vendor/wheels/Injector.cfc`](../../vendor/wheels/Injector.cfc) | +| Background jobs | [`vendor/wheels/Job.cfc`](../../vendor/wheels/Job.cfc), [`vendor/wheels/JobWorker.cfc`](../../vendor/wheels/JobWorker.cfc) | +| Middleware pipeline + built-ins | [`vendor/wheels/middleware/`](../../vendor/wheels/middleware/) (Pipeline, Cors, RateLimiter, SecurityHeaders, RequestId, …) | +| Query builder + scopes | [`vendor/wheels/model/query/QueryBuilder.cfc`](../../vendor/wheels/model/query/QueryBuilder.cfc) | +| Package system | [`vendor/wheels/PackageLoader.cfc`](../../vendor/wheels/PackageLoader.cfc), `ModuleGraph.cfc` | +| Model mass-assignment protection | `accessibleProperties()` / `protectedProperties()` in [`vendor/wheels/model/properties.cfc`](../../vendor/wheels/model/properties.cfc) | +| CLI (`wheels`) incl. `doctor`, `routes --json` | [`cli/lucli/Module.cfc`](../../cli/lucli/Module.cfc) | +| Compatibility matrix CI | [`.github/workflows/compat-matrix.yml`](../../.github/workflows/compat-matrix.yml), [`.github/workflows/pr.yml`](../../.github/workflows/pr.yml) | + +The risk Wheels 4 created is the one this release must answer: these capabilities are real but not yet a *coherent, well-documented, trustworthy whole*. **The first job of Wheels 5 is to make what already shipped cohere — not to pile on more.** + +--- + +## 4. The Wheels 5 thesis + +> The best Wheels 5 is not a bigger Wheels 4. It is a clearer Wheels. + +Concretely, that means a small, decisive scope: + +- **Make the v4 surface coherent and documented** (the consolidation half). +- **Ship two genuinely-missing safety ergonomics** — controller-level strong params and a response object. +- **Make the one forward bet** — AI-legibility. +- **Remove real legacy drag** — without fragmenting a small community's codebase. + +Everything else that earlier drafts proposed (application modules, scheduler/async, config schema, DI discovery, OpenAPI generation, a strategic conformance program) is deferred to 5.1+ or dropped, and is listed in [§9](#9-deferred-to-51) and [§10](#10-what-wheels-5-will-not-do) so the boundary is explicit. + +--- + +## 5. Scope: Wheels 5.0 core + +These are the committed 5.0 deliverables. Each is small, additive where possible, and chosen against the retention / onboarding / forward-bet objective. + +### 5.1 Controller-level strong params + +**Gap (verified):** model-level protection exists (`accessibleProperties`/`protectedProperties`), but there is no filtering at the controller boundary where untrusted input actually enters. `params` is a bare, unguarded struct in `variables` with no accessor. + +**Design — CFML-native, not a Rails transliteration.** Earlier drafts proposed making `params` a Rails-style object with `.require()`/`.permit()`/`.expect()` methods. We reject that for CFML: turning `params` into a CFC that must also behave like a struct invites cross-engine member-function collisions (the same class of bug as `obj.map()` resolving to the built-in struct member — see Cross-Engine Invariant #1 in `CLAUDE.md`). Instead: + +- `params` **stays a plain struct** (zero behavior change, zero engine risk). +- Add controller **helper functions** that operate on it and return a filtered plain struct: + +```cfm +// require a top-level key, permit a fixed set, return a plain struct +userParams = expectParams(params, user = ["name", "email", "timezone"]); +model("User").create(userParams); + +// finer-grained building blocks +requireParam(params, "user"); // 400-style error if missing +permitted = permit(params.user, "name,email"); // whitelist scalars +``` + +- These map missing/tampered input to a **400-style** response (not a 500), matching the intent behind Rails 8's `params.expect`. +- Generators emit this pattern; `wheels doctor` warns on raw `params` passed straight into `create()`/`update()`. + +**Compatibility:** raw `params.user` access keeps working. Strict enforcement (reject unpermitted mass assignment) is opt-in via `set(strictParams = true)`, never default in 5.0. + +### 5.2 Response object + +**Gap (verified):** today's renderers (`renderView`, `renderWith`, `renderText`, `redirectTo`, `sendFile`) are imperative `void` side-effecting functions. There is no unified, testable response builder — which is most painful for the JSON/API and MCP endpoints that the forward bet depends on. + +**Design:** an additive, chainable builder that does not remove any existing helper. + +```cfm +return response().status(201).json(user) + .header("Location", urlFor(route = "user", key = user.id)); + +return response().status(422).json({ errors = user.allErrors() }); +``` + +The value is concentrated where we need it: API controllers, tests (assert status/body/headers consistently), middleware, and a stable shape for AI-generated endpoints. Existing apps need not adopt it. + +### 5.3 RequestContext — formalize, don't fork the idiom + +**Gap (verified):** there is no `RequestContext.cfc`, but the `request.wheels.*` namespace is already rich (`params`, `currentRoute`, `cache`, `execution`, `tenant`, …) and dispatch already builds an ephemeral `{params, route, pathInfo, method, cgi}` context for middleware ([`Dispatch.cfc`](../../vendor/wheels/Dispatch.cfc)). + +**Decision (committed):** formalize the *existing* namespace into a first-class `rc` object available at `variables.rc` and `request.wheels.rc`. `params` and `rc.params` reference the **same** underlying struct (no copies). + +**Explicitly rejected for 5.0: action-argument injection.** Earlier drafts proposed letting actions declare `function show(params, rc)` and having dispatch inject them, and floated *three* coexisting controller styles. We reject this because: + +1. It is the cross-engine-risky part. Actions are dispatched via `$invoke()` with the method name only; no named args are forwarded today. Making injection work consistently across Lucee 5/6/7, Adobe CF 2018–2025, and BoxLang is real, fragile engineering (see the invocation/`argumentCollection`/`attributeCollection` gotchas throughout `CLAUDE.md`). +2. It manufactures the "second competing API" problem. A small community cannot afford three controller idioms in its code, tutorials, and AI training context. + +So there is **one taught idiom**: actions take no request arguments; read `params` (friendly) or `variables.rc` (full context) as needed. `rc` is additive; nothing is forced. + +### 5.4 Legacy removal — decisive but non-fragmenting + +Remove real drag, with a migration path, without breaking the long tail unnecessarily: + +- **Legacy `plugins/` auto-loading** ([`Plugins.cfc`](../../vendor/wheels/Plugins.cfc), 1,131 lines): removed from core; offered as an opt-in `wheels-legacy-plugin-adapter` package for apps that still need it. The modern package system becomes the single extension story. +- **RocketUnit / `wheels.Test`** ([`vendor/wheels/Test.cfc`](../../vendor/wheels/Test.cfc)): still on disk and selectable today with no runtime warning. Add a deprecation warning in the next 4.x, remove from core in 5.0, leave as an external adapter if anyone needs it. `wheels.WheelsTest` is the only documented path. +- **CommandBox-era CLI docs**: removed; the installed `wheels` binary is the only supported surface. + +### 5.5 Single CI matrix manifest + +**Gap (verified):** the engine/DB matrix is hand-encoded and divergent across `compat-matrix.yml`, `pr.yml`, and `tools/test-matrix.sh`, with no shared source of truth. + +**Deliverable:** one `tools/ci/matrix.yml` that declares engines, databases, support levels, and lanes, and feeds: the GitHub Actions matrix, the local runner, the published compatibility table, and `wheels doctor`. This makes the runtime-neutrality promise *executable* rather than aspirational — which is the whole point of neutrality being our brand. + +### 5.6 The forward bet: AI-legibility + +This is the one new theme, and it is deliberately built from pieces that are mostly small extensions of things that already exist: + +- **Machine-readable routing.** `routes --json` already exists; add a stable, documented schema and let routes carry optional metadata (`description`, `tags`, `auth`). The route struct already tolerates extra keys via `argumentCollection` and exposes the matched route at `request.wheels.currentRoute` — we add a *consumer* and a documented contract, not a new mechanism. +- **Request lifecycle trace.** A `wheels trace ` / debug-panel timeline showing matched route, params, middleware, controller/action, and route-model-binding result. This is what lets both humans and agents understand a convention-driven framework. +- **Stable MCP / introspection surface.** Treat the `wheels mcp` stdio surface as a first-class, versioned contract so AI tooling can rely on it. Deprecate the legacy HTTP MCP endpoint. +- **Canonical generators.** Generators emit exactly one idiom (the §5.1/§5.3 patterns), so agent-generated Wheels code converges on what the framework actually wants. + +OpenAPI generation, a full conformance suite, and route-level test generation are **not** in 5.0 (see §9). + +--- + +## 6. Runtime and tooling dependencies (corrected) + +Earlier drafts elevated two single-author projects to strategic pillars. That is the *same* single-vendor concentration risk we criticize in the ColdBox/BoxLang stack — just with different logos. Corrected posture: + +- **BoxLang** — *first-class supported engine, not the primary optimization target.* BoxLang is real (Ortus, 1.0 GA May 2025) but its adoption is vendor-narrated, not measured. The hedge is sound, but it is not permanent: **re-evaluate quarterly.** If adoption inflects, "not primary" can age badly. +- **LuCLI** — *vendored, with an explicit continuity plan.* The `wheels` binary is already a branded fork of LuCLI in [`cli/lucli/`](../../cli/lucli/). LuCLI upstream is a self-declared **alpha (v0.4.0, "expect breaking changes"), single-maintainer** project. The continuity plan is the mitigation, not a relationship: **we own the vendored fork.** We contribute genuinely-generic improvements upstream where convenient, but Wheels' release cadence never blocks on upstream LuCLI, and we do not assume API stability we don't control. +- **RustCFML** — *monitor only; not a roadmap pillar.* It is public ([`github.com/pixl8/RustCFML`](https://github.com/pixl8/RustCFML), Alex Skinner), but it is a ~26-star, AI-authored, single-author experiment **with no ORM** — meaning it cannot run Wheels' model layer at all. It is interesting signal for "AI maintains CFML" (which informs §5.6), nothing more. It does not appear in support tables, gates, or positioning. (Earlier drafts both stated "no public project exists" — false — and made it "the strongest alternative to vertical integration" — unsupportable. Both are corrected here.) + +There is no separate "partnership workstream," no RFC directory gate, and no strategic conformance program in 5.0. Coordination stays lightweight: GitHub issues, milestones, and labels. + +--- + +## 7. Compatibility and deprecation + +**Runtime compatibility stays high.** Existing controllers using `params`, existing views, model APIs, migrations, and modern-package-based extensions continue to work in 5.0. + +**Strictness moves to tooling time, opt-in at runtime:** + +- `wheels doctor` warns on deprecated APIs and risky patterns (raw `params` mass assignment, legacy test base, legacy plugins). +- Generators emit only the new idioms. +- Docs stop teaching removed/legacy patterns. +- `set(strictParams = true)` is the one strict mode shipped in 5.0. Other strict modes are deferred — five opt-in strict modes is configuration sprawl a small community will not adopt coherently. + +**Deprecation policy:** deprecate in a 4.x minor with a runtime/doctor warning and a documented migration → remove in 5.0. Anything removed in 5.0 has a migration path, an upgrade-guide entry, and tests for the new path. + +**Support levels** (rendered from `tools/ci/matrix.yml`): + +- **Primary** — blocks PR/RC gates on failure (Lucee 7 + SQLite at minimum; the engines/DBs ratified in §11). +- **Supported** — tested nightly + RC; failures block release unless documented. +- **Compatibility** — expected to work; failures may be non-blocking for a limited, documented window. + +--- + +## 8. Testing strategy (right-sized) + +A small team needs **two** tiers plus a release gate — not five. + +- **Tier 1 — PR fast lane (blocks merge, target < 10 min).** Lucee latest + SQLite: core WheelsTest, CLI tests, generated-app smoke, minimal browser smoke, `wheels doctor`. Seed already exists in [`pr.yml`](../../.github/workflows/pr.yml). +- **Tier 2 — Nightly full matrix.** All supported engine/DB combinations from the manifest; publishes a dashboard, uploads JUnit/JSON, opens/updates issues for persistent failures. Seed already exists in [`compat-matrix.yml`](../../.github/workflows/compat-matrix.yml). +- **Release gate.** Full matrix green-or-documented, generated-app lifecycle, upgrade tests from latest Wheels 4, **public distribution canaries** (Homebrew/Scoop/apt/yum installed on a clean system), browser tests, docs gate. A clean public install path is part of the trust story and is release-blocking. + +Generated-app smoke testing (run the same `wheels` binary users install: `new` → `generate scaffold` → `migrate latest` → `test` → `start`) catches template/CLI/docs drift that internal unit tests cannot, and is the cheapest high-value test we have. + +--- + +## 9. Deferred to 5.1+ + +Valuable, but not worth the scope risk in 5.0. Each can ship in a minor once the core lands and capacity allows: + +- **Application modules** (bounded `app/modules/` mini-apps). Deferred deliberately: it introduces a *third* extension concept alongside packages and plugins while we are trying to contract, and "Module" is already overloaded (CLI `Module.cfc`, package `ModuleGraph`, TestBox `registerModule`) — naming and concept-count both need design before it ships. +- **Scheduler and lightweight async** on top of existing jobs. +- **Schema-driven configuration** (`wheels config list/get/explain/effective`). +- **Convention-based DI service discovery.** +- **OpenAPI generation** from route metadata. + +--- + +## 10. What Wheels 5 will not do + +Stated plainly so the boundary holds under pressure: + +- **No ColdBox-style platform surface.** No HMVC request lifecycles, no broad AOP/interceptor system, no large named-subsystem taxonomy. +- **No BoxLang-first optimization.** Neutrality is the point. +- **No strategic conformance program.** A thin internal compatibility smoke set is fine; a multi-tier conformance suite sold as market positioning is not — users do not adopt frameworks because of conformance suites. +- **No RustCFML pillar.** Monitor only. +- **No contracts-first/RFC process gate.** Heavyweight process is delivery risk for a small team; keep coordination lightweight. +- **No action-argument injection** and **no three-idiom controllers** (see §5.3). +- **Not a major version for narrative's sake.** The only true breaking changes are the §5.4 removals. If, at beta, the breaking surface is too thin to justify a major, ship the additive work as 4.x minors and reserve "5.0" for when the removals and any idiom shifts actually warrant it. (See §11.) + +--- + +## 11. Open decisions + +These need maintainer ratification before implementation. Each has a recommended default. + +1. **Engine minimums.** *Recommended:* require Lucee 6+ and Adobe 2023+ for 5.0 (drop Lucee 5 / Adobe 2018–2021 from primary), keeping the matrix small and modern. — *Decision needed.* +2. **BoxLang support level for 5.0.** *Recommended:* Supported (not Primary), re-evaluated quarterly. — *Decision needed.* +3. **Oracle release-blocking?** *Recommended:* Supported, soft-fail (as today), not release-blocking. — *Decision needed.* +4. **`strictParams` default.** *Recommended:* warn-only via doctor in 5.0; opt-in enforcement; stricter default no earlier than 6.0. — *Recommended, ratify.* +5. **Is this a 5.0 or 4.x minors?** *Recommended:* proceed as 5.0 contingent on the §5.4 removals landing; re-confirm at beta per §10. — *Decision needed at beta.* +6. **Legacy plugins / RocketUnit:** core removal + external adapter, as in §5.4. — *Recommended, ratify.* +7. **AI-legibility scope for 5.0:** which of {route metadata, lifecycle trace, MCP contract, canonical generators} are core vs. 5.1. *Recommended:* all four are core but minimal; OpenAPI is 5.1. — *Decision needed.* + +--- + +## 12. Sequencing + +Rough order, optimized for landing user-visible value early and de-risking the hard part: + +1. **Coherence pass + matrix manifest (§3, §5.5).** Audit/document the v4 subsystems; unify the CI matrix. Low risk, immediate trust payoff. +2. **Strong params + response object (§5.1, §5.2).** Small, additive, the user-facing headline. +3. **RequestContext formalization (§5.3).** Additive `rc`; no injection. +4. **AI-legibility surface (§5.6).** Route metadata schema, lifecycle trace, MCP contract, canonical generators. +5. **Legacy removal + upgrade guide (§5.4).** The breaking work, once the new paths exist and are documented. + +Each step is shippable and independently valuable, so the release can slip scope without collapsing. + +--- + +## 13. Bottom line + +Wheels 5 should preserve the framework's most valuable quality — a developer can understand it quickly and build useful applications without ceremony — and spend its effort making that simplicity *durable and trustworthy* in a shrinking market: + +- Friendly `params`, backed by safer controller-boundary filtering. +- Simple controllers, backed by an additive request context — one idiom, not three. +- A coherent, documented v4 feature set instead of a bigger pile. +- Runtime neutrality, backed by an executable compatibility matrix. +- One real forward bet — AI-legibility — instead of partnership and conformance machinery. +- Decisive legacy removal, without fragmenting a small community. + +The competitor is attrition, not ColdBox. The win condition is that fewer teams leave CFML because Wheels stayed clear, cheap to maintain, and easy for both people and agents to work in. diff --git a/examples/starter-app/public/Application.cfc b/examples/starter-app/public/Application.cfc index 954ba4e7dc..2bf8c38045 100644 --- a/examples/starter-app/public/Application.cfc +++ b/examples/starter-app/public/Application.cfc @@ -2,7 +2,11 @@ component output="false" { // Put variables we just need internally inside a wheels struct. this.wheels = {}; - this.wheels.rootPath = GetDirectoryFromPath(GetBaseTemplatePath()); + // Anchor to this file's directory, not the requested base template's, so + // rootPath stays stable when a request bootstraps under a subfolder (e.g. + // the test runner) — Hash(rootPath) below seeds this.name, and an unstable + // value splits one app across two application scopes (issue #3025/#2887). + this.wheels.rootPath = GetDirectoryFromPath(GetCurrentTemplatePath()); this.name = createUUID(); // Give this application a unique name by taking the path to the root and hashing it. @@ -91,6 +95,13 @@ component output="false" { include "../config/app.cfm"; + // Issue #3374: bind test-runner / TestClient / browser requests to a + // separate CFML application name so the live application.wheels is never + // swapped. No-op when vendor/wheels is absent. + if (FileExists(GetDirectoryFromPath(GetCurrentTemplatePath()) & "../vendor/wheels/events/testcontext.cfm")) { + include "../vendor/wheels/events/testcontext.cfm"; + } + function onApplicationStart() { // Consume the single-use reload-password handoff left by // $handleRestartAppRequest() for environment-switch restarts (issue #3030). @@ -124,10 +135,22 @@ component output="false" { } public void function onApplicationEnd( struct ApplicationScope ) { - application.wo.$include( - template = "../../#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", - argumentCollection = arguments - ); + // During applicationStop() teardown on Adobe CF 2023 the LIVE `application` + // scope is unreliable — bare `application.wo` can resolve against a + // stale/torn-down scope and land on a Java String[], throwing "Element wo + // is undefined in a Java object of type class [Ljava.lang.String;" (issue + // #3379). The passed-in arguments.applicationScope is the only dependable + // reference at shutdown, so route the call through it and guard it. + if ( + StructKeyExists(arguments.applicationScope, "wo") + && StructKeyExists(arguments.applicationScope, "wheels") + && StructKeyExists(arguments.applicationScope.wheels, "eventPath") + ) { + arguments.applicationScope.wo.$include( + template = "#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", + argumentCollection = arguments + ); + } } public void function onSessionStart() { @@ -299,6 +322,25 @@ component output="false" { // Fail silently if logging fails } } + // Record WHY a requested reload did not fire so the framework's debug + // bar can render a development-only notice instead of a silent no-op + // (issue #3311). Recording is environment-agnostic — a request-scope + // flag, no output; the message text and the development-environment + // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm + // so wording can improve without template drift. Wrong-password and + // rate-limited attempts deliberately collapse into one generic reason + // so the notice adds no oracle on top of $secureCompare(). + if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) { + local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword); + if (!local.reloadPasswordConfigured) { + request.wheels.reloadRefusedReason = "emptyPassword"; + } else if (!StructKeyExists(url, "password")) { + request.wheels.reloadRefusedReason = "missingPasswordParam"; + } else { + request.wheels.reloadRefusedReason = "refused"; + } + } } if (local.reloadAuthorized) { application.wo.$debugPoint("total,reload"); @@ -362,7 +404,7 @@ component output="false" { && StructKeyExists(application.wo, "$restoreTestRunnerApplicationScope") ) { application.wo.$restoreTestRunnerApplicationScope(); - application.wo.$include(template = "../../#application.wheels.eventPath#/onabort.cfm"); + application.wo.$include(template = "#application.wheels.eventPath#/onabort.cfm"); } return true; } diff --git a/examples/tweet/public/Application.cfc b/examples/tweet/public/Application.cfc index 954ba4e7dc..2bf8c38045 100755 --- a/examples/tweet/public/Application.cfc +++ b/examples/tweet/public/Application.cfc @@ -2,7 +2,11 @@ component output="false" { // Put variables we just need internally inside a wheels struct. this.wheels = {}; - this.wheels.rootPath = GetDirectoryFromPath(GetBaseTemplatePath()); + // Anchor to this file's directory, not the requested base template's, so + // rootPath stays stable when a request bootstraps under a subfolder (e.g. + // the test runner) — Hash(rootPath) below seeds this.name, and an unstable + // value splits one app across two application scopes (issue #3025/#2887). + this.wheels.rootPath = GetDirectoryFromPath(GetCurrentTemplatePath()); this.name = createUUID(); // Give this application a unique name by taking the path to the root and hashing it. @@ -91,6 +95,13 @@ component output="false" { include "../config/app.cfm"; + // Issue #3374: bind test-runner / TestClient / browser requests to a + // separate CFML application name so the live application.wheels is never + // swapped. No-op when vendor/wheels is absent. + if (FileExists(GetDirectoryFromPath(GetCurrentTemplatePath()) & "../vendor/wheels/events/testcontext.cfm")) { + include "../vendor/wheels/events/testcontext.cfm"; + } + function onApplicationStart() { // Consume the single-use reload-password handoff left by // $handleRestartAppRequest() for environment-switch restarts (issue #3030). @@ -124,10 +135,22 @@ component output="false" { } public void function onApplicationEnd( struct ApplicationScope ) { - application.wo.$include( - template = "../../#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", - argumentCollection = arguments - ); + // During applicationStop() teardown on Adobe CF 2023 the LIVE `application` + // scope is unreliable — bare `application.wo` can resolve against a + // stale/torn-down scope and land on a Java String[], throwing "Element wo + // is undefined in a Java object of type class [Ljava.lang.String;" (issue + // #3379). The passed-in arguments.applicationScope is the only dependable + // reference at shutdown, so route the call through it and guard it. + if ( + StructKeyExists(arguments.applicationScope, "wo") + && StructKeyExists(arguments.applicationScope, "wheels") + && StructKeyExists(arguments.applicationScope.wheels, "eventPath") + ) { + arguments.applicationScope.wo.$include( + template = "#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", + argumentCollection = arguments + ); + } } public void function onSessionStart() { @@ -299,6 +322,25 @@ component output="false" { // Fail silently if logging fails } } + // Record WHY a requested reload did not fire so the framework's debug + // bar can render a development-only notice instead of a silent no-op + // (issue #3311). Recording is environment-agnostic — a request-scope + // flag, no output; the message text and the development-environment + // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm + // so wording can improve without template drift. Wrong-password and + // rate-limited attempts deliberately collapse into one generic reason + // so the notice adds no oracle on top of $secureCompare(). + if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) { + local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword); + if (!local.reloadPasswordConfigured) { + request.wheels.reloadRefusedReason = "emptyPassword"; + } else if (!StructKeyExists(url, "password")) { + request.wheels.reloadRefusedReason = "missingPasswordParam"; + } else { + request.wheels.reloadRefusedReason = "refused"; + } + } } if (local.reloadAuthorized) { application.wo.$debugPoint("total,reload"); @@ -362,7 +404,7 @@ component output="false" { && StructKeyExists(application.wo, "$restoreTestRunnerApplicationScope") ) { application.wo.$restoreTestRunnerApplicationScope(); - application.wo.$include(template = "../../#application.wheels.eventPath#/onabort.cfm"); + application.wo.$include(template = "#application.wheels.eventPath#/onabort.cfm"); } return true; } diff --git a/package-lock.json b/package-lock.json index 0414fdad0f..8908d222b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1152,9 +1152,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -1368,10 +1368,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/public/Application.cfc b/public/Application.cfc index b1eb617a63..bf64bf6865 100644 --- a/public/Application.cfc +++ b/public/Application.cfc @@ -2,7 +2,11 @@ component output="false" { // Put variables we just need internally inside a wheels struct. this.wheels = {}; - this.wheels.rootPath = GetDirectoryFromPath(GetBaseTemplatePath()); + // Anchor to this file's directory, not the requested base template's, so + // rootPath stays stable when a request bootstraps under a subfolder (e.g. + // the test runner) — Hash(rootPath) below seeds this.name, and an unstable + // value splits one app across two application scopes (issue #3025/#2887). + this.wheels.rootPath = GetDirectoryFromPath(GetCurrentTemplatePath()); this.name = createUUID(); // Give this application a unique name by taking the path to the root and hashing it. @@ -105,6 +109,13 @@ component output="false" { // config/app.cfm can reference this.env safely (issue #2325). include "../config/app.cfm"; + // Issue #3374: bind test-runner / TestClient / browser requests to a + // separate CFML application name so the live application.wheels is never + // swapped. No-op when vendor/wheels is absent (examples without a vendor tree). + if (FileExists(GetDirectoryFromPath(GetCurrentTemplatePath()) & "../vendor/wheels/events/testcontext.cfm")) { + include "../vendor/wheels/events/testcontext.cfm"; + } + function onApplicationStart() { application.env = duplicate(this.env); @@ -152,10 +163,26 @@ component output="false" { } } - application.wo.$include( - template = "../../#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", - argumentCollection = arguments - ); + // Run the framework's onApplicationEnd event through the Wheels global. + // During applicationStop() teardown on Adobe CF 2023 the LIVE `application` + // scope is unreliable — bare `application.wo` can resolve against a + // stale/torn-down scope and land on a Java String[], throwing "Element wo + // is undefined in a Java object of type class [Ljava.lang.String;" and + // erroring the whole site until a CF service restart (issue #3379). The + // passed-in arguments.applicationScope is the only dependable reference at + // shutdown (it is what the $wheelsBrowserLauncher cleanup above uses), so + // route the call through it and guard so a partially reclaimed scope + // degrades to a no-op instead of a hard error. + if ( + StructKeyExists(arguments.applicationScope, "wo") + && StructKeyExists(arguments.applicationScope, "wheels") + && StructKeyExists(arguments.applicationScope.wheels, "eventPath") + ) { + arguments.applicationScope.wo.$include( + template = "#arguments.applicationScope.wheels.eventPath#/onapplicationend.cfm", + argumentCollection = arguments + ); + } } public void function onSessionStart() { @@ -327,6 +354,25 @@ component output="false" { // Fail silently if logging fails } } + // Record WHY a requested reload did not fire so the framework's debug + // bar can render a development-only notice instead of a silent no-op + // (issue #3311). Recording is environment-agnostic — a request-scope + // flag, no output; the message text and the development-environment + // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm + // so wording can improve without template drift. Wrong-password and + // rate-limited attempts deliberately collapse into one generic reason + // so the notice adds no oracle on top of $secureCompare(). + if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) { + local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword); + if (!local.reloadPasswordConfigured) { + request.wheels.reloadRefusedReason = "emptyPassword"; + } else if (!StructKeyExists(url, "password")) { + request.wheels.reloadRefusedReason = "missingPasswordParam"; + } else { + request.wheels.reloadRefusedReason = "refused"; + } + } } if (local.reloadAuthorized) { application.wo.$debugPoint("total,reload"); @@ -390,7 +436,7 @@ component output="false" { && StructKeyExists(application.wo, "$restoreTestRunnerApplicationScope") ) { application.wo.$restoreTestRunnerApplicationScope(); - application.wo.$include(template = "../../#application.wheels.eventPath#/onabort.cfm"); + application.wo.$include(template = "#application.wheels.eventPath#/onabort.cfm"); } return true; } diff --git a/tools/docker/adobe2018/settings.cfm b/tools/docker/adobe2018/settings.cfm index f77774f328..d00afeb248 100644 --- a/tools/docker/adobe2018/settings.cfm +++ b/tools/docker/adobe2018/settings.cfm @@ -3,7 +3,7 @@ Use this file to configure your application. You can also use the environment specific files (e.g. /config/production/settings.cfm) to override settings set here. Don't forget to issue a reload request (e.g. reload=true) after making changes. - See https://wheels.dev/3.1.0/guides/working-with-wheels/configuration-and-defaults for more info. + See https://guides.wheels.dev/v4-0-0/core-concepts/environments-and-configuration/ for more info. */ /* diff --git a/tools/docker/adobe2021/settings.cfm b/tools/docker/adobe2021/settings.cfm index 8944cab58d..41f63067e9 100644 --- a/tools/docker/adobe2021/settings.cfm +++ b/tools/docker/adobe2021/settings.cfm @@ -3,7 +3,7 @@ Use this file to configure your application. You can also use the environment specific files (e.g. /config/production/settings.cfm) to override settings set here. Don't forget to issue a reload request (e.g. reload=true) after making changes. - See https://wheels.dev/3.1.0/guides/working-with-wheels/configuration-and-defaults for more info. + See https://guides.wheels.dev/v4-0-0/core-concepts/environments-and-configuration/ for more info. */ /* diff --git a/tools/docker/adobe2023/settings.cfm b/tools/docker/adobe2023/settings.cfm index 8944cab58d..41f63067e9 100644 --- a/tools/docker/adobe2023/settings.cfm +++ b/tools/docker/adobe2023/settings.cfm @@ -3,7 +3,7 @@ Use this file to configure your application. You can also use the environment specific files (e.g. /config/production/settings.cfm) to override settings set here. Don't forget to issue a reload request (e.g. reload=true) after making changes. - See https://wheels.dev/3.1.0/guides/working-with-wheels/configuration-and-defaults for more info. + See https://guides.wheels.dev/v4-0-0/core-concepts/environments-and-configuration/ for more info. */ /* diff --git a/tools/docker/adobe2025/settings.cfm b/tools/docker/adobe2025/settings.cfm index 8944cab58d..41f63067e9 100644 --- a/tools/docker/adobe2025/settings.cfm +++ b/tools/docker/adobe2025/settings.cfm @@ -3,7 +3,7 @@ Use this file to configure your application. You can also use the environment specific files (e.g. /config/production/settings.cfm) to override settings set here. Don't forget to issue a reload request (e.g. reload=true) after making changes. - See https://wheels.dev/3.1.0/guides/working-with-wheels/configuration-and-defaults for more info. + See https://guides.wheels.dev/v4-0-0/core-concepts/environments-and-configuration/ for more info. */ /* diff --git a/tools/docker/boxlang/settings.cfm b/tools/docker/boxlang/settings.cfm index 8944cab58d..41f63067e9 100644 --- a/tools/docker/boxlang/settings.cfm +++ b/tools/docker/boxlang/settings.cfm @@ -3,7 +3,7 @@ Use this file to configure your application. You can also use the environment specific files (e.g. /config/production/settings.cfm) to override settings set here. Don't forget to issue a reload request (e.g. reload=true) after making changes. - See https://wheels.dev/3.1.0/guides/working-with-wheels/configuration-and-defaults for more info. + See https://guides.wheels.dev/v4-0-0/core-concepts/environments-and-configuration/ for more info. */ /* diff --git a/tools/docker/lucee5/settings.cfm b/tools/docker/lucee5/settings.cfm index bf18b9a722..4afb0514e5 100644 --- a/tools/docker/lucee5/settings.cfm +++ b/tools/docker/lucee5/settings.cfm @@ -3,7 +3,7 @@ Use this file to configure your application. You can also use the environment specific files (e.g. /config/production/settings.cfm) to override settings set here. Don't forget to issue a reload request (e.g. reload=true) after making changes. - See https://wheels.dev/3.1.0/guides/working-with-wheels/configuration-and-defaults for more info. + See https://guides.wheels.dev/v4-0-0/core-concepts/environments-and-configuration/ for more info. */ /* diff --git a/tools/docker/lucee6/settings.cfm b/tools/docker/lucee6/settings.cfm index bf18b9a722..4afb0514e5 100644 --- a/tools/docker/lucee6/settings.cfm +++ b/tools/docker/lucee6/settings.cfm @@ -3,7 +3,7 @@ Use this file to configure your application. You can also use the environment specific files (e.g. /config/production/settings.cfm) to override settings set here. Don't forget to issue a reload request (e.g. reload=true) after making changes. - See https://wheels.dev/3.1.0/guides/working-with-wheels/configuration-and-defaults for more info. + See https://guides.wheels.dev/v4-0-0/core-concepts/environments-and-configuration/ for more info. */ /* diff --git a/tools/docker/lucee7/settings.cfm b/tools/docker/lucee7/settings.cfm index bf18b9a722..4afb0514e5 100644 --- a/tools/docker/lucee7/settings.cfm +++ b/tools/docker/lucee7/settings.cfm @@ -3,7 +3,7 @@ Use this file to configure your application. You can also use the environment specific files (e.g. /config/production/settings.cfm) to override settings set here. Don't forget to issue a reload request (e.g. reload=true) after making changes. - See https://wheels.dev/3.1.0/guides/working-with-wheels/configuration-and-defaults for more info. + See https://guides.wheels.dev/v4-0-0/core-concepts/environments-and-configuration/ for more info. */ /* diff --git a/tools/docker/testui/package-lock.json b/tools/docker/testui/package-lock.json index 2cf0c040c7..eacb18e78c 100644 --- a/tools/docker/testui/package-lock.json +++ b/tools/docker/testui/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "puppeteer": "^22.8.2", - "vue-tsc": "^2.0.28" + "vue-tsc": "^3.3.8" } }, "node_modules/@babel/code-frame": { @@ -472,30 +472,30 @@ } }, "node_modules/@volar/language-core": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", - "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", "dev": true, "license": "MIT", "dependencies": { - "@volar/source-map": "2.4.15" + "@volar/source-map": "2.4.28" } }, "node_modules/@volar/source-map": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", - "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", "dev": true, "license": "MIT" }, "node_modules/@volar/typescript": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", - "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.15", + "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } @@ -550,17 +550,6 @@ "@vue/shared": "3.5.13" } }, - "node_modules/@vue/compiler-vue2": { - "version": "2.7.16", - "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", - "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", - "dev": true, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - }, "node_modules/@vue/devtools-api": { "version": "7.7.6", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.6.tgz", @@ -595,28 +584,19 @@ } }, "node_modules/@vue/language-core": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", - "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.8.tgz", + "integrity": "sha512-ieGT8jJdhhy0mGzStZhsg/qPw5bQZJg5yF+3+XU6saf4sM7yo9ZXy3h+nCwrm2+b4qS/SypkNdR2jAF3uei9tA==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.15", + "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", - "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", - "alien-signals": "^1.0.3", - "minimatch": "^9.0.3", + "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" } }, "node_modules/@vue/reactivity": { @@ -680,9 +660,9 @@ } }, "node_modules/alien-signals": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", - "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", "dev": true, "license": "MIT" }, @@ -739,13 +719,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/bare-events": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.1.tgz", @@ -879,16 +852,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1042,13 +1005,6 @@ "node": ">= 14" } }, - "node_modules/de-indent": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", - "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", - "dev": true, - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -1329,16 +1285,6 @@ "node": ">= 14" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", @@ -1412,9 +1358,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "dev": true, "license": "MIT", "engines": { @@ -1458,10 +1404,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1752,22 +1708,6 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -1789,9 +1729,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -1952,9 +1892,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.24", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", + "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", "funding": [ { "type": "opencollective", @@ -1971,7 +1911,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2492,14 +2432,14 @@ "license": "MIT" }, "node_modules/vue-tsc": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", - "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.8.tgz", + "integrity": "sha512-xXmYlVQpcwJDWyGlqbHrGVOl1h3UOsASymRibrHc+iy9j/UNnOrOn4u+fntHz4D6Cs74RtapeqVV6CzJeg+UlA==", "dev": true, "license": "MIT", "dependencies": { - "@volar/typescript": "2.4.15", - "@vue/language-core": "2.2.12" + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.8" }, "bin": { "vue-tsc": "bin/vue-tsc.js" @@ -2534,9 +2474,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { diff --git a/tools/docker/testui/package.json b/tools/docker/testui/package.json index db6d786fe6..dd146d46af 100644 --- a/tools/docker/testui/package.json +++ b/tools/docker/testui/package.json @@ -23,6 +23,6 @@ }, "devDependencies": { "puppeteer": "^22.8.2", - "vue-tsc": "^2.0.28" + "vue-tsc": "^3.3.8" } } diff --git a/tools/docs-validation/package-lock.json b/tools/docs-validation/package-lock.json index dde6e6772b..28e98791a7 100644 --- a/tools/docs-validation/package-lock.json +++ b/tools/docs-validation/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@wheels/docs-validation", "version": "0.1.0", + "license": "Apache-2.0", "dependencies": { "@anthropic-ai/sdk": "^0.40.0" }, @@ -181,16 +182,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -301,9 +302,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" diff --git a/tools/gh-open-refresh-baseline-pr.sh b/tools/gh-open-refresh-baseline-pr.sh new file mode 100755 index 0000000000..b95f37a1b8 --- /dev/null +++ b/tools/gh-open-refresh-baseline-pr.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Deliver a refreshed visual-regression baseline to the dispatched branch: +# push it directly when the branch allows that, and fall back to opening a +# `chore/refresh-baseline-*` PR when the branch ruleset rejects direct pushes. +# +# Extracted from .github/workflows/refresh-visual-baselines.yml for issue #3283: +# that workflow's final step pushed the refreshed PNG(s) straight to the +# dispatched branch, which the `develop` branch ruleset now rejects: +# +# remote: error: GH013: Repository rule violations found for refs/heads/develop. +# remote: - Changes must be made through a pull request. +# +# Delivery strategy: +# 1. Commit the refreshed PNG(s), then try the direct push. On an +# unprotected branch (the header's documented flow — dispatching on a +# PR's source branch) this succeeds and behaves exactly like the +# pre-#3283 workflow: the commit lands immediately and retriggers CI. +# 2. If the push is rejected (GH013 on `develop`, or any other rejection), +# move the commit to a throwaway `chore/refresh-baseline-*` branch and +# open a PR against the target branch instead — mirroring +# .github/workflows/refresh-packages-baseline.yml. +# +# The fallback PR is NOT auto-merged, on purpose. It is opened with the +# workflow's GITHUB_TOKEN, and GitHub's recursive-trigger guard means +# GITHUB_TOKEN-authored PRs never fire `pull_request` workflows — the target +# branch's required checks would sit "Expected" forever, so enabling +# auto-merge would just wedge silently. (The sibling +# refresh-packages-baseline.yml documents the same gotcha and also leaves its +# PR for a human.) A maintainer must eyeball the PNG diff and merge; closing +# and reopening the PR as a human retriggers CI if the required checks are +# wanted first. +# +# Contract: +# - CWD is the repository root. +# - The working tree already holds the refreshed, UNSTAGED baseline file(s). +# - Writes `delivery=push|pr` (and `pr_url=...` for the PR path) to +# $GITHUB_OUTPUT when set, so the workflow's step summary can report what +# actually happened. +# +# Inputs (all via env, never spliced into an eval'd string — this preserves the +# workflow's Actions-injection-safe posture; see the workflow's security note): +# SITES which baseline(s) were refreshed (used in the title/body/branch) +# TARGET_BRANCH branch the refresh lands on (github.ref_name) +# RUN_ID github.run_id — makes the throwaway branch name unique +# RUN_ATTEMPT github.run_attempt — keeps re-runs of a failed job unique too +# (a bare RUN_ID collides with the branch left by attempt 1) +# ADD_PATHS git pathspec(s) to stage (default: web/tests/visual-baselines/) +# GH_TOKEN token with BOTH contents:write and pull-requests:write +# +# SITES is constrained to the workflow's `choice` input list and RUN_ID / +# RUN_ATTEMPT are numeric, so all are safe to interpolate into the branch name +# and the --title/--body arguments (each reaches git/gh as a single argv +# element, never a shell-expanded command string). + +set -euo pipefail + +: "${SITES:?SITES is required}" +: "${TARGET_BRANCH:?TARGET_BRANCH is required}" +: "${RUN_ID:?RUN_ID is required}" +RUN_ATTEMPT="${RUN_ATTEMPT:-1}" +ADD_PATHS="${ADD_PATHS:-web/tests/visual-baselines/}" +GITHUB_OUTPUT="${GITHUB_OUTPUT:-/dev/null}" + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +# Word-split ADD_PATHS on purpose so callers can pass multiple pathspecs; the +# value is workflow-controlled, never user input. +# shellcheck disable=SC2086 +git add -- $ADD_PATHS + +# Build the commit message in a temp file so the heredoc body stays readable and +# no value is spliced into a shell-expanded string. The subject stays a valid +# conventional commit <=100 chars, which the "Validate Commit Messages" +# required check lints against the PR title in the fallback path. The branch +# name lives on its own body line so a long TARGET_BRANCH can't push a body +# line past commitlint's 100-char body-max-line-length. +commit_msg_file="$(mktemp)" +{ + printf '%s\n\n' "chore(web): refresh visual baseline(s) ($SITES)" + printf '%s\n' "Manually triggered baseline refresh via" + printf '%s\n' ".github/workflows/refresh-visual-baselines.yml" + printf '%s\n\n' "targeting branch $TARGET_BRANCH." + printf '%s\n' "Run when an intentional content/layout change makes the visual-regression" + printf '%s\n' "check fail. The new PNG(s) under web/tests/visual-baselines/ are now the" + printf '%s\n' "expected rendering." +} > "$commit_msg_file" + +git commit -F "$commit_msg_file" +rm -f "$commit_msg_file" + +# Fast path: branches without a "changes must arrive via PR" rule (typically a +# PR's source branch, the flow the workflow header documents) still take the +# pre-#3283 direct push. The guard means a ruleset rejection (GH013) no longer +# fails the job — it falls through to the PR flow below. +if git push origin "HEAD:${TARGET_BRANCH}"; then + echo "Pushed refreshed baseline(s) directly to ${TARGET_BRANCH} (branch accepts direct pushes)." + echo "delivery=push" >> "$GITHUB_OUTPUT" + exit 0 +fi + +echo "Direct push to ${TARGET_BRANCH} was rejected (ruleset requires a PR?) — opening a refresh PR instead." + +branch="chore/refresh-baseline-${SITES}-${RUN_ID}-${RUN_ATTEMPT}" +git checkout -b "$branch" +git push -u origin "$branch" + +pr_body_file="$(mktemp)" +{ + printf '%s\n\n' "## Summary" + printf '%s\n\n' "Refreshes the visual-regression baseline(s) for \`$SITES\` so the \`visual-regression\` check reflects the intended rendering." + printf '%s\n\n' "Opened automatically by \`.github/workflows/refresh-visual-baselines.yml\` (run \`$RUN_ID\`, attempt \`$RUN_ATTEMPT\`): the direct push was rejected because the \`$TARGET_BRANCH\` branch ruleset requires changes to arrive via a pull request." + printf '%s\n\n' "> **Maintainer note:** this PR was opened with the workflow's \`GITHUB_TOKEN\`, so required checks will NOT start on their own (GitHub suppresses workflow triggers on GITHUB_TOKEN-authored PRs). Either close and reopen the PR to trigger CI, or eyeball the PNG diff below and merge." + printf '%s\n' "## Test plan" + printf '%s\n' "- [ ] The baseline diff contains only the intended content/layout change (not font/rendering drift)" + printf '%s\n' "- [ ] After merge, \`visual-regression\` passes on the target branch's next run" +} > "$pr_body_file" + +# gh prints the new PR's URL on stdout; capture it for the log + step summary. +pr_url="$(gh pr create \ + --base "$TARGET_BRANCH" \ + --head "$branch" \ + --title "chore(web): refresh visual baseline(s) ($SITES)" \ + --body-file "$pr_body_file")" + +rm -f "$pr_body_file" + +echo "Opened refresh PR: $pr_url" +echo "NOTE: required checks do not auto-run on GITHUB_TOKEN-authored PRs; a maintainer must" +echo "review the PNG diff and merge (or close/reopen the PR to trigger CI first)." +echo "delivery=pr" >> "$GITHUB_OUTPUT" +echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT" diff --git a/tools/rustcfml/ENGINE_VERSION b/tools/rustcfml/ENGINE_VERSION new file mode 100644 index 0000000000..aaf93b7e8e --- /dev/null +++ b/tools/rustcfml/ENGINE_VERSION @@ -0,0 +1 @@ +v0.429.0 diff --git a/tools/rustcfml/baseline.json b/tools/rustcfml/baseline.json new file mode 100644 index 0000000000..70cccbad9d --- /dev/null +++ b/tools/rustcfml/baseline.json @@ -0,0 +1,28 @@ +{ + "engineVersion": "v0.429.0", + "totals": { + "totalSpecs": 4711, + "totalPass": 4638, + "totalFail": 15, + "totalError": 40, + "totalSkipped": 18 + }, + "failing": [ + "wheels.tests.specs.controller.verifiesSpec :: Tests that verifies :: checks that strings allow blank", + "wheels.tests.specs.controller.verifiesSpec :: Tests that verifies :: checks valid types", + "wheels.tests.specs.controller.verifiesSpec :: Tests that verifies :: is valid", + "wheels.tests.specs.controller.verifiesSpec :: Tests that verifies :: throws at declaration time when a types list length does not match its variable list", + "wheels.tests.specs.global.publicSpec :: Tests that processrequest :: processes request as GET", + "wheels.tests.specs.global.reloadGlobalsSpec :: Reload \u2014 global includes mtime tracking (issue #2792) :: $globalIncludesChanged returns false when no files changed", + "wheels.tests.specs.global.reloadGlobalsSpec :: Reload \u2014 global includes mtime tracking (issue #2792) :: $globalIncludesChanged returns true when a new cfm file appears", + "wheels.tests.specs.global.reloadGlobalsSpec :: Reload \u2014 global includes mtime tracking (issue #2792) :: $globalIncludesChanged returns true when a tracked cfm file is modified", + "wheels.tests.specs.global.reloadGlobalsSpec :: Reload \u2014 global includes mtime tracking (issue #2792) :: $globalIncludesChanged returns true when a tracked cfm file is removed", + "wheels.tests.specs.migrator.migrationSpec :: Tests addIndex :: creates an index on multiple columns", + "wheels.tests.specs.wheelstest.BrowserLauncherSpec :: $castForParam :: casts numeric to java.lang.Double for double param type", + "wheels.tests.specs.wheelstest.BrowserLauncherSpec :: $castForParam :: casts numeric to java.lang.Integer for int param type", + "wheels.tests.specs.wheelstest.BrowserLauncherSpec :: $castForParam :: passes Java objects through unchanged", + "wheels.tests.specs.wheelstest.BrowserLauncherSpec :: $findSetter :: finds a one-arg setter by name on a JDK class", + "wheels.tests.specs.wheelstest.BrowserLauncherSpec :: $findSetter :: throws BrowserOptionError for nonexistent setter", + "wheels.tests.specs.wheelstest.BrowserLauncherSpec :: BrowserLauncher path discovery :: $findZeroArgMethod throws BrowserLauncherReflectionError when method missing" + ] +} diff --git a/tools/rustcfml/run-suite.sh b/tools/rustcfml/run-suite.sh new file mode 100755 index 0000000000..d94c087c94 --- /dev/null +++ b/tools/rustcfml/run-suite.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Run the Wheels core suite on the pinned RustCFML engine build and compare the +# outcome against the checked-in known-failure baseline (tools/rustcfml/baseline.json). +# +# RustCFML is a JVM-free CFML engine under active development. This lane is +# informational: it is never a merge gate. Pass criteria is "no NEW failures +# versus the baseline", not zero failures — a set of known residual errors +# (no-JVM limitations and open upstream engine issues) is expected and tracked +# in the baseline file. +# +# Usage: +# bash tools/rustcfml/run-suite.sh # compare against baseline +# bash tools/rustcfml/run-suite.sh --write-baseline # regenerate baseline.json +# # (run after bumping ENGINE_VERSION) +# +# Environment overrides: +# RUSTCFML_BIN path to an existing engine binary (skips download) +# RUSTCFML_PORT port to serve on (default 8513) +# +# Exit codes: 0 = no new failures (or baseline written); 1 = boot break, new +# failures, or infrastructure error. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$DIR/../.." && pwd)" +VERSION="$(tr -d '[:space:]' < "$DIR/ENGINE_VERSION")" +BASELINE="$DIR/baseline.json" +PORT="${RUSTCFML_PORT:-8513}" +MODE="compare" +[ "${1:-}" = "--write-baseline" ] && MODE="write" + +# --- resolve engine binary (download once, cache by version) ------------------ +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) ASSET="rustcfml-linux-x86_64" ;; + Linux-aarch64) ASSET="rustcfml-linux-aarch64" ;; + Darwin-arm64) ASSET="rustcfml-macos-aarch64" ;; + Darwin-x86_64) ASSET="rustcfml-macos-x86_64" ;; + *) echo "unsupported platform: $(uname -s)-$(uname -m)"; exit 1 ;; +esac + +BIN="${RUSTCFML_BIN:-}" +if [ -z "$BIN" ]; then + CACHE_DIR="${RUSTCFML_CACHE_DIR:-$HOME/.cache/wheels-rustcfml}" + mkdir -p "$CACHE_DIR" + BIN="$CACHE_DIR/rustcfml-$VERSION" + if [ ! -x "$BIN" ]; then + echo "Downloading RustCFML $VERSION ($ASSET)..." + gh release download "$VERSION" --repo RustCFML/RustCFML --pattern "$ASSET" --output "$BIN" + chmod +x "$BIN" + fi +fi +echo "Engine: $BIN" + +# --- serve the repo webroot ---------------------------------------------------- +SERVE_LOG="$(mktemp)" +OUT="$(mktemp)" +WHEELS_CI=true "$BIN" --serve "$REPO_ROOT/public" --port "$PORT" > "$SERVE_LOG" 2>&1 & +SERVE_PID=$! +cleanup() { + kill "$SERVE_PID" 2>/dev/null || true + rm -f "$SERVE_LOG" "$OUT" +} +trap cleanup EXIT + +UP=0 +for _ in $(seq 1 30); do + CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 1 "http://127.0.0.1:$PORT/" 2>/dev/null || true) + if [ "$CODE" != "000" ] && [ -n "$CODE" ]; then UP=1; break; fi + sleep 1 +done +if [ "$UP" != 1 ]; then + echo "ENGINE DID NOT START — serve log tail:"; tail -20 "$SERVE_LOG"; exit 1 +fi + +# Warm boot, then run the suite. The /index.cfm/ prefix works around RustCFML +# issue #194 (path-info routing without the prefix 404s under urlrewrite). +curl -s -o /dev/null --max-time 120 "http://127.0.0.1:$PORT/index.cfm/" || true +curl -s --max-time 900 \ + "http://127.0.0.1:$PORT/index.cfm/wheels/core/tests?db=sqlite&format=json" \ + -o "$OUT" || { echo "suite request failed"; exit 1; } + +# --- parse + compare ----------------------------------------------------------- +python3 - "$OUT" "$BASELINE" "$MODE" "$VERSION" <<'PY' +import json, os, sys + +out_path, baseline_path, mode, version = sys.argv[1:5] + +raw = open(out_path, encoding="utf-8", errors="replace").read().lstrip() +try: + # The suite response can carry stray trailing bytes — tolerate them. + data, _ = json.JSONDecoder().raw_decode(raw) +except Exception as exc: + print(f"BOOT BREAK: suite returned unparseable output ({exc}); first 400 bytes:") + print(raw[:400]) + sys.exit(1) + +totals = {k: int(data.get(k, 0)) for k in + ("totalSpecs", "totalPass", "totalFail", "totalError", "totalSkipped")} + +failing = set() +def walk(node, bundle, path): + for spec in node.get("specStats", []): + if spec.get("status") not in ("Passed", "Skipped"): + failing.add(f"{bundle} :: {path} :: {spec.get('name', '?')}") + for nested in node.get("nestedSuiteStats", []) or []: + walk(nested, bundle, f"{path} > {nested.get('name', '?')}") + +for b in data.get("bundleStats", []): + name = b.get("name", "?") + ge = b.get("globalException") or {} + if isinstance(ge, dict) and ge.get("message"): + failing.add(f"{name} :: (bundle-level exception)") + for su in b.get("suiteStats", []): + walk(su, name, su.get("name", "?")) + +print(f"RustCFML {version}: {totals['totalPass']} pass, {totals['totalFail']} fail, " + f"{totals['totalError']} error, {totals['totalSkipped']} skipped " + f"({len(failing)} distinct failing entries)") + +if totals["totalPass"] == 0: + print("BOOT BREAK: zero passing specs — the engine could not run the suite.") + print("Response head (the suite likely returned an error payload):") + print(raw[:600]) + sys.exit(1) + +if mode == "write": + payload = { + "engineVersion": version, + "totals": totals, + "failing": sorted(failing), + } + with open(baseline_path, "w") as fh: + json.dump(payload, fh, indent=2) + fh.write("\n") + print(f"Baseline written to {baseline_path} ({len(failing)} known-failing entries).") + sys.exit(0) + +try: + baseline = json.load(open(baseline_path)) +except Exception: + print(f"No readable baseline at {baseline_path} — run with --write-baseline first.") + sys.exit(1) + +known = set(baseline.get("failing", [])) +new = sorted(failing - known) +fixed = sorted(known - failing) + +# Coarse totals backstop: the failing[] walk only sees per-spec entries and +# bundle-level exceptions, so a regression surfacing through a response shape +# the walk doesn't reach (lifecycle/suite-level errors) could raise the totals +# without adding a named entry. Flag totalFail/totalError rising above baseline +# even when the named diff is empty. +base_totals = baseline.get("totals", {}) +totals_worse = [ + f"{key}: {int(base_totals.get(key, 0))} -> {totals[key]}" + for key in ("totalFail", "totalError") + if totals[key] > int(base_totals.get(key, 0)) +] + +summary_lines = [] +if fixed: + summary_lines.append(f"NEWLY PASSING vs baseline ({len(fixed)}):") + summary_lines += [f" + {item}" for item in fixed] + summary_lines.append(" (baseline can be refreshed with --write-baseline)") +if new: + summary_lines.append(f"NEW FAILURES vs baseline ({len(new)}):") + summary_lines += [f" - {item}" for item in new] +if totals_worse: + summary_lines.append("TOTALS REGRESSION vs baseline (no named entry — check response shape):") + summary_lines += [f" - {item}" for item in totals_worse] +for line in summary_lines: + print(line) + +step_summary = os.environ.get("GITHUB_STEP_SUMMARY") +if step_summary: + with open(step_summary, "a") as fh: + fh.write(f"## RustCFML {version} (experimental lane)\n\n") + fh.write(f"{totals['totalPass']} pass / {totals['totalFail']} fail / " + f"{totals['totalError']} error / {totals['totalSkipped']} skipped — " + f"baseline {baseline.get('engineVersion', '?')}\n\n") + for line in summary_lines: + fh.write(line + "\n") + if not new and not totals_worse: + fh.write("\nNo new failures versus baseline.\n") + +sys.exit(1 if (new or totals_worse) else 0) +PY diff --git a/tools/test-local.sh b/tools/test-local.sh index 17815c7f0b..d895611dcd 100755 --- a/tools/test-local.sh +++ b/tools/test-local.sh @@ -32,7 +32,11 @@ DB="${DB:-sqlite}" # Must match set(reloadPassword=...) in config/settings.cfm — a mismatch never # reloads and, since #3062, counts against the per-IP reload rate limit. PASSWORD="wheels-dev" -RESULT_FILE="/tmp/wheels-local-test-results.json" +# Per-checkout results file. A single fixed /tmp path is shared by every checkout on +# the machine, so two working copies running the suite overwrite each other's results — +# and a develop-vs-branch comparison silently becomes two copies of the same run +# (issue #3352). Keyed on the project root so concurrent checkouts stay separate. +RESULT_FILE="${WHEELS_TEST_RESULT_FILE:-/tmp/wheels-local-test-results-$(echo "$PROJECT_ROOT" | shasum | cut -c1-12).json}" # Browser specs call back into the local Wheels CLI server — point Playwright # at the right port. CI sets this explicitly before invoking the script; @@ -124,11 +128,17 @@ if [ -n "$FILTER" ]; then middleware) FILTER="wheels.tests.specs.middleware" ;; dispatch) FILTER="wheels.tests.specs.dispatch" ;; migrator) FILTER="wheels.tests.specs.migrator" ;; + internal) FILTER="wheels.tests.specs.internal" ;; + interfaces) FILTER="wheels.tests.specs.interfaces" ;; esac TEST_URL="${TEST_URL}&directory=${FILTER}" fi echo "Running tests: Lucee 7 + SQLite${FILTER:+ (filter: $FILTER)}" +# Clear it first. When the request fails outright — a server that is not up yet reports +# HTTP 000 — curl may write nothing, leaving the PREVIOUS run's results sitting there to +# be read as if they were this run's (issue #3352). A crashed run must leave no result. +rm -f "$RESULT_FILE" HTTP_CODE=$(curl -s -o "$RESULT_FILE" \ --max-time 600 \ --write-out "%{http_code}" \ diff --git a/vendor/wheels/Controller.cfc b/vendor/wheels/Controller.cfc index 69acf34a9a..13afa6c0ea 100644 --- a/vendor/wheels/Controller.cfc +++ b/vendor/wheels/Controller.cfc @@ -375,69 +375,58 @@ component output="false" displayName="Controller" extends="wheels.Global"{ * @path The path to get component files from */ private function $integrateComponents(required string path) { - local.basePath = arguments.path; - local.folderPath = expandPath("/#replace(local.basePath, ".", "/", "all")#"); - - // Get a list of all CFC files in the folder - local.fileList = directoryList(local.folderPath, false, "name", "*.cfc"); - for (local.fileName in local.fileList) { - // Remove the file extension to get the component name - local.componentName = replace(local.fileName, ".cfc", "", "all"); - - $integrateFunctions(createObject("component", "#local.basePath#.#local.componentName#")); + // The directory scan + per-file createObject + getMetaData, plus the + // public-method/reference resolution, are cached per path (issue #3213) — + // they are identical for every controller instance. Only the reference + // assignment below runs on each materialization. The mixin-override set is + // resolved once per call (empty in the common no-mixins case) so the old + // per-method $willBeOverriddenByMixin function call is gone from the loop. + local.plan = $componentIntegrationPlan(arguments.path); + local.overrideSet = $mixinOverrideSet("controller"); + local.iEnd = ArrayLen(local.plan); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + $integrateFunctions(local.plan[local.i].publicMethods, local.overrideSet); } } /** - * Dynamically mix methods from a given component into this component + * Mix a component's pre-resolved public methods (each `{name, ref}`, see + * $componentIntegrationPlan) into this instance. A method that does not already + * exist (from inheritance or an earlier-integrated component) is added; one that + * DOES already exist is left alone and the framework original is exposed as + * `super`, so an app override can delegate to it. Any method a + * plugin/package mixin will override is likewise aliased. `overrideSet` is the + * precomputed mixin-override name set. + * + * The `super` else-branch matches Model.cfc, which has always had it. Its + * absence here meant an app that overrode a controller or view helper — exactly + * as the "Overriding Core Methods" guide documents — got no `superLinkTo()` and a + * 500 at render time (issue #3325, from discussion #3323). Only app overrides + * reach the branch: no two framework mixins contribute the same name, so a + * controller that overrides nothing gains zero extra keys. */ - private function $integrateFunctions(componentInstance) { - // Get all methods from the given component - local.methods = getMetaData(componentInstance).functions; - - for (local.method in local.methods) { - local.functionName = local.method.name; - - // Only add public, non-inherited methods - if (local.method.access eq "public") { - local.methodExists = structKeyExists(variables, local.method.name) || structKeyExists(this, local.method.name); - - if (!local.methodExists) { - variables[local.functionName] = componentInstance[local.functionName]; - this[local.functionName] = componentInstance[local.functionName]; - } - - // Only add super prefix for functions that will be overridden by plugins/mixins - if ($willBeOverriddenByMixin(local.functionName)) { - local.superMethodName = "super" & local.functionName; - variables[local.superMethodName] = componentInstance[local.functionName]; - this[local.superMethodName] = componentInstance[local.functionName]; - } - + private function $integrateFunctions(required array publicMethods, required struct overrideSet) { + local.iEnd = ArrayLen(arguments.publicMethods); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.m = arguments.publicMethods[local.i]; + local.name = local.m.name; + local.ref = local.m.ref; + + if (!(StructKeyExists(variables, local.name) || StructKeyExists(this, local.name))) { + variables[local.name] = local.ref; + this[local.name] = local.ref; + } else { + local.superName = "super" & local.name; + variables[local.superName] = local.ref; + this[local.superName] = local.ref; } - } - } - /** - * Check if a function will be overridden by a plugin/mixin - */ - private boolean function $willBeOverriddenByMixin(required string functionName) { - // Check if application and mixins are available - if (!IsDefined("application") || !StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "mixins")) { - return false; - } - - // Check for both "controller" and "global" mixins - local.componentTypes = ["controller", "global"]; - - for (local.componentType in local.componentTypes) { - if (StructKeyExists(application.wheels.mixins, local.componentType) && - StructKeyExists(application.wheels.mixins[local.componentType], arguments.functionName)) { - return true; + if (StructKeyExists(arguments.overrideSet, local.name)) { + local.superName = "super" & local.name; + variables[local.superName] = local.ref; + this[local.superName] = local.ref; } } - - return false; } function onDIcomplete(){ diff --git a/vendor/wheels/Global.cfc b/vendor/wheels/Global.cfc index 5e0cca6a8c..ff031240c9 100644 --- a/vendor/wheels/Global.cfc +++ b/vendor/wheels/Global.cfc @@ -1,195 +1,25 @@ component output="false" { - public any function $doubleCheckedLock( - required string name, - required string condition, - required string execute, - struct conditionArgs = "#StructNew()#", - struct executeArgs = "#StructNew()#", - numeric timeout = 30 - ) { - local.rv = $invoke(method = arguments.condition, invokeArgs = arguments.conditionArgs); - if (IsBoolean(local.rv) AND NOT local.rv) { - lock timeout="#arguments.timeout#" name="#arguments.name#" { - local.rv = $invoke(method = arguments.condition, invokeArgs = arguments.conditionArgs); - if (IsBoolean(local.rv) AND NOT local.rv) { - local.rv = $invoke(method = arguments.execute, invokeArgs = arguments.executeArgs) - } - } - } - return local.rv; - } - - public any function $simpleLock( - required string name, - required string type, - required string execute, - struct executeArgs = "#StructNew()#", - numeric timeout = 30 - ) { - if (StructKeyExists(arguments, "object")) { - lock name="#arguments.name#" type="#arguments.type#" timeout="#arguments.timeout#" { - local.rv = $invoke( - component = "#arguments.object#", - method = "#arguments.execute#", - argumentCollection = "#arguments.executeArgs#" - ); - } - } else { - arguments.executeArgs.$locked = true; - lock name="#arguments.name#" type="#arguments.type#" timeout="#arguments.timeout#" { - local.rv = $invoke(method = "#arguments.execute#", argumentCollection = "#arguments.executeArgs#"); - } - } - if (StructKeyExists(local, "rv")) { - return local.rv; - } - } - - public struct function $image() { - local.rv = {}; - if (arguments.action == "info") { - local.rv = $engineAdapter().imageInfo(arguments.source); - } else if ($engineAdapter().isBoxLang()) { - Throw( - type = "Wheels.Image.UnsupportedAction", - message = "The `$image()` function in BoxLang currently supports only the 'info' action." - ); - } else { - // Adobe or Lucee: use cfimage - arguments.structName = "rv"; - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; - } - cfimage(attributeCollection = local.args); - local.rv = local.rv; - } - return local.rv; - } - - public void function $mail() { - if (StructKeyExists(arguments, "mailparts")) { - local.mailparts = arguments.mailparts; - StructDelete(arguments, "mailparts"); - } - if (StructKeyExists(arguments, "mailparams")) { - local.mailparams = arguments.mailparams; - StructDelete(arguments, "mailparams"); - } - if (StructKeyExists(arguments, "tagContent")) { - local.tagContent = arguments.tagContent; - StructDelete(arguments, "tagContent"); - } - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; - } - cfmail(attributeCollection = "#local.args#") { - if (StructKeyExists(local, "mailparams")) { - for (local.i in local.mailparams) { - cfmailparam(attributeCollection = "#local.i#"); - } - } - if (StructKeyExists(local, "mailparts")) { - for (local.i in local.mailparts) { - local.innerTagContent = local.i.tagContent; - StructDelete(local.i, "tagContent"); - cfmailpart(attributeCollection = "#local.i#") { - WriteOutput(local.innerTagContent) - } - } - } - if (StructKeyExists(local, "tagContent")) { - WriteOutput(local.tagContent) - } - } - } - - public any function $cache() { - // If cache is found only the function is aborted, not page. ---> - variables.$instance.reCache = false; - // Engines without the `cfcache` built-in (e.g. RustCFML) can't back - // the template/static cache. Degrade to a no-op: leaving reCache=true - // means the request still renders normally, just without this layer. - if ($hasEngineAdapter() && !$engineAdapter().supportsCfcache()) { - variables.$instance.reCache = true; - return; - } - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; - } - cfcache(attributeCollection = "#local.args#"); - variables.$instance.reCache = true; - } - - public void function $content() { - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; - } - // Best-effort: cfcontent throws on a committed response (Adobe CF). - if ($responseCommitted()) { - return; - } - try { - cfcontent(attributeCollection = "#local.args#"); - } catch (any e) { - // Re-probe to handle the isCommitted/throw race; rethrow only when - // the response is still uncommitted (a genuine caller error). - if (!$responseCommitted()) { - rethrow; - } - } - } - - public void function $header() { - // Plain-struct copy: Adobe CF 2023+ rejects `arguments` as - // attributeCollection (#10 cross-engine invariant). `statusText` is - // stripped because Adobe CF 2025 removed it. - local.args = {}; - for (local.key in arguments) { - if (local.key != "statusText") { - local.args[local.key] = arguments[local.key]; - } - } - // Best-effort: cfheader throws on a committed response (Adobe CF). The - // short-circuit is critical inside onError, where letting the exception - // escape would replace the original error with the cfheader-failure stack. - if ($responseCommitted()) { - return; - } - try { - cfheader(attributeCollection = "#local.args#"); - } catch (any e) { - // Re-probe to handle the isCommitted/throw race; rethrow only when - // the response is still uncommitted (a genuine caller error). - if (!$responseCommitted()) { - rethrow; - } - } - } - - /** - * Returns true when the servlet response has been committed and headers - * can no longer be modified. Returns false on engines or contexts where - * the underlying servlet probe is unavailable. - */ - public boolean function $responseCommitted() { - try { - return GetPageContext().getResponse().isCommitted(); - } catch (any e) { - return false; - } - } - + // These four helpers MUST stay as methods of this CFC (not in + // vendor/wheels/global/*.cfm). Lucee compiles a function from a + // component-body include as a UDF of that include template + // (`global.tags_cfm$cf.udfCall`). `include` inside those UDFs then + // resolves relative to `vendor/wheels/global/` and does not apply + // application mappings the same way a method on wheels.Global does — + // so `onAbort`'s `$include("../../#eventPath#/onabort.cfm")` looks + // for `/app/events/onabort.cfm` under the webroot and 500s. Keep + // every `include` statement that apps rely on for mapping-absolute + // or `../../`-prefixed event/config paths here (issue ##3241). public void function $include(required string template) { - include "#LCase(arguments.template)#"; + // Hoist the resolve: a function call inside the `include` attribute + // is one Adobe teardown trigger. Mapping-absolute includes still + // fail after applicationStop() drops THIS.mappings — fall back + // via $tryIncludeTemplate (issue ##3241). + $tryIncludeTemplate($resolveGlobalIncludeTemplate(arguments.template)); } public void function $includeAndOutput(required string template) { - include "#LCase(arguments.template)#"; + $tryIncludeTemplate($resolveGlobalIncludeTemplate(arguments.template)); } public string function $includeAndReturnOutput(required string $template) { @@ -199,12 +29,44 @@ component output="false" { } // Include the template and return the result. // Variable is set to $wheels to limit chances of it being overwritten in the included template. + // Include stays in this function: `local = arguments` above must be + // visible to partials, and savecontent must wrap the include itself + // (a helper on this output=false CFC would capture nothing). // cfformat-ignore-start - savecontent variable="local.$wheels" { - include "#LCase(arguments.$template)#" - }; + local.resolved = $resolveGlobalIncludeTemplate(arguments.$template); + var includeState = {done = false, output = ""}; + var captured = ""; + try { + savecontent variable="captured" { + include "#local.resolved#" + }; + includeState.output = captured; + includeState.done = true; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; + } + } + if (!includeState.done) { + var fallbacks = $mappedIncludeFallbacks(local.resolved); + var fbCount = ArrayLen(fallbacks); + for (var fbIndex = 1; fbIndex <= fbCount; fbIndex++) { + try { + savecontent variable="captured" { + include "#fallbacks[fbIndex]#" + }; + includeState.output = captured; + includeState.done = true; + break; + } catch (any e) { + if (fbIndex == fbCount || !$isMissingMappedInclude(e)) { + rethrow; + } + } + } + } // cfformat-ignore-end -return local.$wheels; + return includeState.output; } /** @@ -237,9 +99,39 @@ return local.$wheels; public void function $includeConfig(required string template) { try { // cfformat-ignore-start - savecontent variable="local.$wheelsConfigOutput" { - include "#LCase(arguments.template)#" - }; + local.resolved = $resolveGlobalIncludeTemplate(arguments.template); + var configIncludeState = {done = false, output = ""}; + var configCaptured = ""; + try { + savecontent variable="configCaptured" { + include "#local.resolved#" + }; + configIncludeState.output = configCaptured; + configIncludeState.done = true; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; + } + } + if (!configIncludeState.done) { + var configFallbacks = $mappedIncludeFallbacks(local.resolved); + var configFbCount = ArrayLen(configFallbacks); + for (var configFbIndex = 1; configFbIndex <= configFbCount; configFbIndex++) { + try { + savecontent variable="configCaptured" { + include "#configFallbacks[configFbIndex]#" + }; + configIncludeState.output = configCaptured; + configIncludeState.done = true; + break; + } catch (any e) { + if (configFbIndex == configFbCount || !$isMissingMappedInclude(e)) { + rethrow; + } + } + } + } + local.$wheelsConfigOutput = configIncludeState.output; // cfformat-ignore-end } catch (any e) { // Fail closed: a compile-time or runtime failure in a config template is a @@ -296,4146 +188,366 @@ return local.$wheels; } } - public any function $directory() { - local.rv = ""; - arguments.name = "rv"; - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; + /** + * Rewrite `$include` templates so Application.cfc's + * `../../#eventPath#/onabort.cfm` (eventPath is `/app/events`) becomes + * the mapping-absolute `/app/events/onabort.cfm`. + * + * `"../../" & "/app/events/onabort.cfm"` concatenates to + * `../../../app/events/onabort.cfm`. After the DC7 split, `$include` + * compiled from `vendor/wheels/global/tags.cfm` resolved that against + * the include (or the webroot) and looked for + * `public/app/events/onabort.cfm` — LuCLI 1 fail / 4 error, Lucee + * smoke `onabort` / `onapplicationend` misses (issue ##3241). Collapse + * a leading `../` chain as if the include lived on this CFC + * (`/wheels/Global.cfc`). Mapping-absolute paths (`/app/...`, + * `/config/...`, `/wheels/...`) and other relative templates are + * unchanged except for the historical LCase. + */ + public string function $resolveGlobalIncludeTemplate(required string template) { + var normalized = Replace(arguments.template, "\", "/", "all"); + if (!Len(normalized)) { + return normalized; } - cfdirectory(attributeCollection = "#local.args#"); - return local.rv; - } - - public any function $file() { - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; + if (Left(normalized, 1) == "/") { + return LCase(normalized); } - cffile(attributeCollection = "#local.args#"); - } - - public any function $cfinvoke(required string component, required string method, struct invokeArguments) { - cfinvoke - component = "#arguments.component#" - method = "#arguments.method#" - returnVariable = "#arguments.returnVariable#" - argumentCollection = "#arguments.invokeArguments#"; - return local.rv; - } - - public any function $invoke() { - arguments.returnVariable = "local.rv"; - if (StructKeyExists(arguments, "componentReference")) { - arguments.component = arguments.componentReference; - StructDelete(arguments, "componentReference"); - } else if (NOT StructKeyExists(variables, arguments.method)) { - // this is done so that we can call dynamic methods via "onMissingMethod" on the object (we need to pass in the object for this so it can call methods on the "this" scope instead) - arguments.component = this; + if (Left(normalized, 3) != "../") { + return LCase(normalized); } - if (StructKeyExists(arguments, "invokeArgs")) { - arguments.argumentCollection = arguments.invokeArgs; - if (StructCount(arguments.argumentCollection) IS NOT ListLen(StructKeyList(arguments.argumentCollection))) { - // work-around for fasthashremoved cf8 bug - arguments.argumentCollection = StructNew(); - for (local.i in StructKeyList(arguments.invokeArgs)) { - arguments.argumentCollection[local.i] = arguments.invokeArgs[local.i]; - } + var segments = ["wheels"]; + var parts = ListToArray(normalized, "/"); + var partCount = ArrayLen(parts); + for (var partIndex = 1; partIndex <= partCount; partIndex++) { + var part = parts[partIndex]; + if (!Len(part) || part == ".") { + continue; } - - - if (StructKeyExists(arguments.invokeArgs, "componentReference")) { - arguments.component = arguments.invokeArgs.componentReference; + if (part == "..") { + if (ArrayLen(segments)) { + ArrayDeleteAt(segments, ArrayLen(segments)); + } + } else { + ArrayAppend(segments, part); } - - - StructDelete(arguments, "invokeArgs"); - } - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; } - cfinvoke(attributeCollection = "#local.args#"); - if (StructKeyExists(local, "rv")) { - return local.rv; + if (!ArrayLen(segments)) { + return "/"; } + return "/" & LCase(ArrayToList(segments, "/")); } - public void function $location(boolean delay = false) { - StructDelete(arguments, "$args", false); - if (NOT arguments.delay) { - StructDelete(arguments, "delay", false); - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; - } - cflocation(attributeCollection = "#local.args#"); + /** + * True when `include` failed because a CF mapping (`/wheels`, `/app`) + * is gone — Adobe CF 2023 `applicationStop()` teardown — not because + * the template has a compile/runtime error. + */ + public boolean function $isMissingMappedInclude(required any exception) { + var text = arguments.exception.message; + if (StructKeyExists(arguments.exception, "detail") && IsSimpleValue(arguments.exception.detail)) { + text &= " " & arguments.exception.detail; + } + if (FindNoCase("Could not find the included template", text)) { + return true; } + // Lucee: Page [/wheels/...] [filesystem path] not found + if (FindNoCase("Page [", text) && FindNoCase("not found", text)) { + return true; + } + return false; } - public void function $htmlhead() { - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; + /** + * Mapping-free include paths for a mapping-absolute template. + * [1] relative to this CFC (`vendor/wheels/Global.cfc`). + * [2] relative to the front controller (`public/index.cfm`). + * Pure so it can be unit-tested without applicationStop(). + */ + public array function $mappedIncludeFallbacks(required string template) { + var normalized = Replace(arguments.template, "\", "/", "all"); + if (!Len(normalized) || Left(normalized, 1) != "/") { + return []; } - // Best-effort: cfhtmlhead throws "Unable to add text to HTML HEAD tag" - // on a committed response (Adobe CF). Same defensive shape as $header(). - if ($responseCommitted()) { - return; + if (Left(normalized, 8) == "/wheels/") { + return [ + Mid(normalized, 9, Len(normalized)), + "../vendor" & normalized + ]; } + return [ + "../.." & normalized, + ".." & normalized + ]; + } + + /** + * `include` a mapping-absolute template, then the mapping-free + * fallbacks when Adobe teardown has dropped THIS.mappings. + * Page/event includes only — cluster function files must stay as + * component-body includes so UDFs compile into this CFC. + */ + public void function $tryIncludeTemplate(required string template) { + var resolved = arguments.template; + var state = {done = false}; try { - cfhtmlhead(attributeCollection = "#local.args#"); + include "#resolved#"; + state.done = true; } catch (any e) { - // Re-probe to handle the isCommitted/throw race; rethrow only when - // the response is still uncommitted (a genuine caller error). - if (!$responseCommitted()) { + if (!$isMissingMappedInclude(e)) { rethrow; } } - } - - public any function $dbinfo() { - arguments.name = "local.rv"; - if (StructKeyExists(arguments, "username") && !Len(arguments.username)) { - StructDelete(arguments, "username"); + if (state.done) { + return; } - if (StructKeyExists(arguments, "password") && !Len(arguments.password)) { - StructDelete(arguments, "password"); + var fallbacks = $mappedIncludeFallbacks(resolved); + var fbCount = ArrayLen(fallbacks); + if (!fbCount) { + throw( + type = "Wheels.MissingInclude", + message = "Could not include template '" & resolved & "'" + ); } - - // BoxLang specific fix for index queries (MSSQL/Oracle) - if ( - $engineAdapter().isBoxLang() && - StructKeyExists(arguments, "type") && arguments.type == "index" && - StructKeyExists(arguments, "table") - ) { - local.adapter = $get("adapterName"); - - if (local.adapter == "MicrosoftSQLServerModel") { - local.sql = " - SELECT - DB_NAME() AS TABLE_CAT, - SCHEMA_NAME(t.schema_id) AS TABLE_SCHEM, - t.name AS TABLE_NAME, - CAST(CASE WHEN i.is_unique = 0 THEN 1 ELSE 0 END AS INT) AS NON_UNIQUE, - t.name AS INDEX_QUALIFIER, - i.name AS INDEX_NAME, - CASE - WHEN i.type = 1 THEN 'Clustered Index' - WHEN i.type = 2 THEN 'Other Index' - ELSE 'Other Index' - END AS TYPE, - CAST(ic.key_ordinal AS INT) AS ORDINAL_POSITION, - c.name AS COLUMN_NAME, - CASE WHEN ic.is_descending_key = 0 THEN 'A' ELSE 'D' END AS ASC_OR_DESC, - CAST(0 AS INT) AS CARDINALITY, - CAST(0 AS INT) AS PAGES, - '' AS FILTER_CONDITION - FROM sys.indexes i - INNER JOIN sys.objects t ON i.object_id = t.object_id - INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id - INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id - WHERE t.name = '#arguments.table#' - AND t.type = 'U' - AND i.type_desc IN ('CLUSTERED', 'NONCLUSTERED') - ORDER BY i.name, ic.key_ordinal - "; - local.rv = $query(sql = local.sql, datasource = arguments.datasource); - return local.rv; - } - - if (local.adapter == "OracleModel") { - local.sql = " - SELECT - NULL AS TABLE_CAT, - ai.OWNER AS TABLE_SCHEM, - ai.TABLE_NAME, - CASE WHEN ai.UNIQUENESS = 'NONUNIQUE' THEN 1 ELSE 0 END AS NON_UNIQUE, - ai.OWNER AS INDEX_QUALIFIER, - ai.INDEX_NAME, - 'Other Index' AS TYPE, - ac.COLUMN_POSITION AS ORDINAL_POSITION, - ac.COLUMN_NAME, - CASE WHEN ac.DESCEND = 'DESC' THEN 'D' ELSE 'A' END AS ASC_OR_DESC, - 0 AS CARDINALITY, - 0 AS PAGES, - '' AS FILTER_CONDITION - FROM ALL_INDEXES ai - JOIN ALL_IND_COLUMNS ac ON ai.INDEX_NAME = ac.INDEX_NAME AND ai.OWNER = ac.INDEX_OWNER - WHERE ai.TABLE_NAME = UPPER('#arguments.table#') - AND ai.INDEX_TYPE != 'LOB' - ORDER BY ai.INDEX_NAME, ac.COLUMN_POSITION - "; - local.rv = $query(sql = local.sql, datasource = arguments.datasource); - return local.rv; + for (var fbIndex = 1; fbIndex <= fbCount; fbIndex++) { + try { + include "#fallbacks[fbIndex]#"; + return; + } catch (any e) { + if (fbIndex == fbCount || !$isMissingMappedInclude(e)) { + rethrow; + } } } + } - if ( - StructKeyExists(arguments, "type") && - arguments.type eq "index" && - $get("adapterName") eq "SQLiteModel" - ) { - local.sql = " - SELECT - NULL AS TABLE_CAT, - NULL AS TABLE_SCHEM, - '#arguments.table#' AS TABLE_NAME, - CASE WHEN il.""unique"" = 0 THEN 1 ELSE 0 END AS NON_UNIQUE, - NULL AS INDEX_QUALIFIER, - il.name AS INDEX_NAME, - 'Other Index' AS TYPE, - ii.seqno + 1 AS ORDINAL_POSITION, - ii.name AS COLUMN_NAME, - 'A' AS ASC_OR_DESC, - 0 AS CARDINALITY, - 0 AS PAGES, - '' AS FILTER_CONDITION - FROM pragma_index_list('#arguments.table#') il - JOIN pragma_index_info(il.name) ii - - UNION ALL - - SELECT - NULL AS TABLE_CAT, - NULL AS TABLE_SCHEM, - '#arguments.table#' AS TABLE_NAME, - 0 AS NON_UNIQUE, - NULL AS INDEX_QUALIFIER, - 'PRIMARY' AS INDEX_NAME, - 'Primary Key' AS TYPE, - pk AS ORDINAL_POSITION, - name AS COLUMN_NAME, - 'A' AS ASC_OR_DESC, - 0 AS CARDINALITY, - 0 AS PAGES, - '' AS FILTER_CONDITION - FROM pragma_table_info('#arguments.table#') - WHERE pk > 0 - - ORDER BY INDEX_NAME, ORDINAL_POSITION; - "; - local.rv = $query(sql = local.sql, datasource = arguments.datasource); - return local.rv; + // Focused collaborators for the former Global.cfc monolith (issue ##3241). + // Component-body includes compile into this CFC so every Global-derived + // type (Model, Controller, Dispatch, …) inherits the helpers with no + // per-instance mixin copy. Each include MUST be wrapped in cfscript + // tags — an include is tag-context, so bare script would leak as + // output (same contract as /app/global/functions.cfm). + // + // Mapping-absolute `/wheels/...` is the boot path. Adobe CF 2023 + // applicationStop() drops THIS.mappings before onApplicationEnd; the + // next Global method call re-evaluates these includes and + // `include "/wheels/global/locking.cfm"` 500s (authorized reload + // probe, issue ##3241). Fall back to a path relative to this CFC, + // then a path relative to public/index.cfm. Do not move these includes + // into a method — method-body includes declare UDFs locally, not on + // the component (see $reincludeGlobals). + try { + include "/wheels/global/locking.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; } - - // If the cfdbinfo call fails we try it again, this time setting "dbname" explicitly. - // Sometimes the call fails when using a custom database connection string. - // In that case the database name is not known by the CF server and it will just use any of the databases that the data source has access to. - // That can incorrectly be "information_schema" for example. try { - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; - } - cfdbinfo(attributeCollection = local.args); - } catch (any e) { - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; - } - cfdbinfo(attributeCollection = local.args); - local.type = arguments.type; - arguments.type = "dbnames"; - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; - } - cfdbinfo(attributeCollection = local.args); - if (local.rv.recordCount GT 1) { - for (local.i in local.rv) { - if (local.i.database_name IS NOT "information_schema") { - arguments.dbname = local.i.database_name; - } - } - } - arguments.type = local.type; - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; - } - cfdbinfo(attributeCollection = local.args); - } - - // Override name for test mode - if ( - arguments.type IS "version" AND - StructKeyExists(url, "controller") AND - StructKeyExists(url, "action") AND - StructKeyExists(url, "view") AND - StructKeyExists(url, "type") AND - StructKeyExists(url, "adapter") - ) { - if (url.controller IS "wheels" AND url.action IS "wheels" AND url.view IS "tests" AND url.type IS "core") { - QuerySetCell(local.rv, "driver_name", url.adapter); + include "global/locking.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; } + include "../vendor/wheels/global/locking.cfm"; } - - return local.rv; } - - public any function $wddx(required any input, string action = "cfml2wddx", boolean useTimeZoneInfo = true) { - arguments.output = "local.output"; - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; + try { + include "/wheels/global/tags.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; } - cfwddx(attributeCollection = "#local.args#"); - if (StructKeyExists(local, "output")) { - return local.output; + try { + include "global/tags.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; + } + include "../vendor/wheels/global/tags.cfm"; } } - - public any function $zip() { - $engineAdapter().prepareZipArgs(arguments); - local.args = {}; - for (local.key in arguments) { - local.args[local.key] = arguments[local.key]; + try { + include "/wheels/global/settings.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; + } + try { + include "global/settings.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; + } + include "../vendor/wheels/global/settings.cfm"; } - cfzip(attributeCollection = "#local.args#"); } - - public any function $query(required string sql) { - StructDelete(arguments, "name"); - // allow the use of query of queries, caveat: Query must be called query. Eg: SELECT * from query - if (StructKeyExists(arguments, "query") && IsQuery(arguments.query)) { - var query = Duplicate(arguments.query); + try { + include "/wheels/global/cache.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; } - local.rv = QueryExecute(PreserveSingleQuotes(arguments.sql), [], arguments); - // some sql statements may not return a value - if (StructKeyExists(local, "rv")) { - return local.rv; + try { + include "global/cache.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; + } + include "../vendor/wheels/global/cache.cfm"; } } - - /** - * Returns the current setting for the supplied Wheels setting or the current default for the supplied Wheels function argument. - * - * [section: Configuration] - * [category: Miscellaneous Functions] - * - * @name Variable name to get setting for. - * @functionName Function name to get setting for. - */ - public any function get(required string name, string functionName = "") { - return $get(argumentCollection = arguments); - } - - /** - * Returns the value of an environment variable. Checks application.env (loaded from .env files) first, then falls back to system environment variables (server.system.environment). Returns the default if the variable is not found in either location. - * - * [section: Configuration] - * [category: Miscellaneous Functions] - * - * @name The environment variable name to look up. - * @defaultValue Value to return if the variable is not found. The legacy - * named argument `default` is also accepted for backwards compatibility - * with pre-rename callers. - */ - public any function env(required string name, any defaultValue = "") { - if (StructKeyExists(application, "env") && StructKeyExists(application.env, arguments.name)) { - return application.env[arguments.name]; + try { + include "/wheels/global/objects.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; } - if ( - StructKeyExists(server, "system") - && StructKeyExists(server.system, "environment") - && StructKeyExists(server.system.environment, arguments.name) - ) { - return server.system.environment[arguments.name]; - } - // Back-compat for the legacy `default = "Y"` named-arg form. The - // parameter was renamed from `default` (a CFML reserved word Adobe CF - // refuses to bind) to `defaultValue`; named arguments still land in - // `arguments` under their literal key on every engine. - if (StructKeyExists(arguments, "default")) { - return arguments.default; + try { + include "global/objects.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; + } + include "../vendor/wheels/global/objects.cfm"; } - return arguments.defaultValue; - } - - /** - * Use to configure a global setting or set a default for a function. - * - * [section: Configuration] - * [category: Miscellaneous Functions] - */ - public void function set() { - $set(argumentCollection = arguments); } - - /** - * Internal function. - * Called from get(). - */ - public any function $get(required string name, string functionName = "") { - // Multi-tenant config override: per-tenant settings take precedence - // over application-level settings (non-function settings only). - // Security-sensitive settings cannot be overridden per-tenant. - // Use a StructKeyExists chain for safe nested scope traversal during app - // startup (IsDefined string-parses its dotted-path argument on every call - // and $get runs on every settings read so it's too expensive here). - if ( - !Len(arguments.functionName) - && StructKeyExists(request, "wheels") - && StructKeyExists(request.wheels, "tenant") - && StructKeyExists(request.wheels.tenant, "config") - && StructKeyExists(request.wheels.tenant.config, arguments.name) - && !ListFindNoCase( - "encryptionAlgorithm,encryptionSecretKey,encryptionEncoding,CSRFProtection,csrfStore,reloadPassword,obfuscateUrls", - arguments.name - ) - ) { - return request.wheels.tenant.config[arguments.name]; + try { + include "/wheels/global/routing.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; } - local.appKey = $appKey(); - if (Len(arguments.functionName)) { - local.rv = application[local.appKey].functions[arguments.functionName][arguments.name]; - } else { - local.rv = application[local.appKey][arguments.name]; + try { + include "global/routing.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; + } + include "../vendor/wheels/global/routing.cfm"; } - return local.rv; } - - /** - * Internal function. - * Called from set(). - */ - public void function $set() { - local.appKey = $appKey(); - if (ArrayLen(arguments) > 1) { - for (local.key in arguments) { - if (local.key != "functionName") { - local.functionNameArray = ListToArray(arguments.functionName); - local.iEnd = ArrayLen(local.functionNameArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.functionName = Trim(local.functionNameArray[local.i]); - application[local.appKey].functions[local.functionName][local.key] = arguments[local.key]; - } - } + try { + include "/wheels/global/strings.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; + } + try { + include "global/strings.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; } - } else { - application[local.appKey][StructKeyList(arguments)] = arguments[1]; + include "../vendor/wheels/global/strings.cfm"; } } - - // ====================================================================== - // MULTI-TENANCY FUNCTIONS - // ====================================================================== - - /** - * Returns the current tenant struct, or an empty struct if no tenant is active. - * The tenant struct contains: `id`, `dataSource`, `config`, and `$locked`. - * - * [section: Configuration] - * [category: Multi-Tenancy] - */ - public struct function tenant() { - if (IsDefined("request.wheels.tenant")) { - return request.wheels.tenant; + try { + include "/wheels/global/request.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; } - return {}; - } - - /** - * Returns the current tenant's datasource name, or the application default if no tenant is active. - * - * [section: Configuration] - * [category: Multi-Tenancy] - */ - public string function $tenantDataSource() { - if ( - IsDefined("request.wheels.tenant.dataSource") - && Len(request.wheels.tenant.dataSource) - ) { - return request.wheels.tenant.dataSource; + try { + include "global/request.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; + } + include "../vendor/wheels/global/request.cfm"; } - return $get("dataSourceName"); } - - /** - * Switches the active tenant mid-request. Throws if the current tenant is locked - * (set by TenantResolver middleware) unless `force` is true. - * - * [section: Configuration] - * [category: Multi-Tenancy] - * - * @tenant Struct with at minimum a `dataSource` key. Optional: `id`, `config`. - * @force If true, overrides the lock set by TenantResolver middleware. - */ - public void function switchTenant(required struct tenant, boolean force = false) { - if (!StructKeyExists(arguments.tenant, "dataSource") || !Len(arguments.tenant.dataSource)) { - Throw(type = "Wheels.InvalidTenant", message = "The tenant struct must contain a non-empty `dataSource` key."); - } - if (!StructKeyExists(request, "wheels")) { - request.wheels = {}; + try { + include "/wheels/global/util.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; } - // Check if current tenant is locked - if ( - !arguments.force - && IsDefined("request.wheels.tenant") - && StructKeyExists(request.wheels.tenant, "$locked") - && request.wheels.tenant["$locked"] - ) { - Throw( - type = "Wheels.TenantLocked", - message = "Cannot switch tenants mid-request. The current tenant was set by middleware and is locked.", - extendedInfo = "Use `switchTenant(tenant={...}, force=true)` to override, or remove the lock in your middleware configuration." - ); + try { + include "global/util.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; + } + include "../vendor/wheels/global/util.cfm"; } - // Set defaults - if (!StructKeyExists(arguments.tenant, "id")) { - arguments.tenant.id = ""; + } + try { + include "/wheels/global/plugins.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; } - if (!StructKeyExists(arguments.tenant, "config")) { - arguments.tenant.config = {}; + try { + include "global/plugins.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; + } + include "../vendor/wheels/global/plugins.cfm"; } - request.wheels.tenant = arguments.tenant; } - - // ====================================================================== - // CACHE FUNCTIONS - // ====================================================================== - - /** - * Creates a unique string based on any arguments passed in (used as a key for caching mostly). - */ - public string function $hashedKey() { - local.rv = ""; - - // make all cache keys domain specific (do not use request scope below since it may not always be initialized) - StructInsert(arguments, ListLen(StructKeyList(arguments)) + 1, cgi.http_host, true); - - // we need to make sure we are looping through the passed in arguments in the same order everytime - local.values = []; - local.keyList = ListSort(StructKeyList(arguments), "textnocase", "asc"); - local.keyArray = ListToArray(local.keyList); - local.iEnd = ArrayLen(local.keyArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - ArrayAppend(local.values, arguments[local.keyArray[local.i]]); + try { + include "/wheels/global/pagination.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; } - - if (!ArrayIsEmpty(local.values)) { - // this might fail if a query contains binary data so in those rare cases we fall back on using cfwddx (which is a little bit slower which is why we don't use it all the time) - try { - local.rv = SerializeJSON(local.values); - local.rv = $engineAdapter().normalizeForHash(local.rv); - } catch (any e) { - local.rv = $wddx(input = local.values); + try { + include "global/pagination.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; } + include "../vendor/wheels/global/pagination.cfm"; } - return Hash(local.rv); - } - - /** - * Internal function. - * Case-sensitive, constant-time string comparison. Both values are hashed with - * SHA-256 before being compared via MessageDigest.isEqual so the comparison - * neither leaks length information nor exits early on the first differing byte. - * Used by the reload/restart password gate and the environment-switch gate. - */ - public boolean function $secureCompare(required string candidate, required string comparedValue) { - return CreateObject("java", "java.security.MessageDigest").isEqual( - Hash(arguments.candidate, "SHA-256").getBytes("UTF-8"), - Hash(arguments.comparedValue, "SHA-256").getBytes("UTF-8") - ); } - - /** - * Internal function. - */ - public any function $timeSpanForCache( - required any cache, - numeric defaultCacheTime = application.wheels.defaultCacheTime, - string cacheDatePart = application.wheels.cacheDatePart - ) { - local.cache = arguments.defaultCacheTime; - if (IsNumeric(arguments.cache)) { - local.cache = arguments.cache; + try { + include "/wheels/global/cors.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; } - local.listArray = [0, 0, 0, 0]; - local.dateParts = "d,h,n,s"; - local.datePartsArray = ListToArray(local.dateParts); - local.iEnd = ArrayLen(local.datePartsArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - if (arguments.cacheDatePart == local.datePartsArray[local.i]) { - local.listArray[local.i] = local.cache; + try { + include "global/cors.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; } + include "../vendor/wheels/global/cors.cfm"; } - local.rv = CreateTimespan(local.listArray[1], local.listArray[2], local.listArray[3], local.listArray[4]); - return local.rv; } - - /** - * Internal function. - */ - public void function $addToCache( - required string key, - required any value, - numeric time = application.wheels.defaultCacheTime, - string category = "main" - ) { - local.currentCount = $cacheCount(); - if ( - application.wheels.cacheCullPercentage > 0 - && application.wheels.cacheLastCulledAt < DateAdd("n", -application.wheels.cacheCullInterval, Now()) - && local.currentCount >= application.wheels.maximumItemsToCache - ) { - // the cache is full so flush out expired items to make more room if possible - // (the maximum applies to the cache as a whole so we cull across all categories, - // otherwise a write to a small category would free nothing and get dropped) - local.deletedItems = 0; - if (application.wheels.cacheCullPercentage < 100) { - local.maxItemsToDelete = Ceiling(local.currentCount * application.wheels.cacheCullPercentage / 100); - } else { - local.maxItemsToDelete = local.currentCount; - } - local.now = Now(); - local.categories = StructKeyArray(application.wheels.cache); - local.iEnd = ArrayLen(local.categories); - for (local.i = 1; local.i <= local.iEnd && local.deletedItems < local.maxItemsToDelete; local.i++) { - local.cacheCategory = local.categories[local.i]; - // snapshot the keys so we never delete from the struct we are iterating over - local.cacheKeys = StructKeyArray(application.wheels.cache[local.cacheCategory]); - local.jEnd = ArrayLen(local.cacheKeys); - for (local.j = 1; local.j <= local.jEnd && local.deletedItems < local.maxItemsToDelete; local.j++) { - local.cacheKey = local.cacheKeys[local.j]; - if ( - StructKeyExists(application.wheels.cache[local.cacheCategory], local.cacheKey) - && local.now > application.wheels.cache[local.cacheCategory][local.cacheKey].expiresAt - ) { - $removeFromCache(key = local.cacheKey, category = local.cacheCategory); - local.deletedItems++; - } - } - } - local.currentCount -= local.deletedItems; - application.wheels.cacheLastCulledAt = Now(); + try { + include "/wheels/global/lifecycle.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; } - if (local.currentCount < application.wheels.maximumItemsToCache) { - local.cacheItem = {}; - local.cacheItem.expiresAt = DateAdd(application.wheels.cacheDatePart, arguments.time, Now()); - if (IsSimpleValue(arguments.value)) { - local.cacheItem.value = arguments.value; - } else { - local.cacheItem.value = Duplicate(arguments.value); + try { + include "global/lifecycle.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; } - application.wheels.cache[arguments.category][arguments.key] = local.cacheItem; + include "../vendor/wheels/global/lifecycle.cfm"; } } - /** - * Internal function. - */ - public any function $getFromCache(required string key, string category = "main") { - local.rv = false; - try { - if (StructKeyExists(application.wheels.cache[arguments.category], arguments.key)) { - if (Now() > application.wheels.cache[arguments.category][arguments.key].expiresAt) { - $removeFromCache(key = arguments.key, category = arguments.category); - } else { - if (IsSimpleValue(application.wheels.cache[arguments.category][arguments.key].value)) { - local.rv = application.wheels.cache[arguments.category][arguments.key].value; - } else { - local.rv = Duplicate(application.wheels.cache[arguments.category][arguments.key].value); - } - } - } - } catch (any e) { - } - return local.rv; - } - - /** - * Internal function. - */ - public void function $removeFromCache(required string key, string category = "main") { - StructDelete(application.wheels.cache[arguments.category], arguments.key); - } - - /** - * Internal function. - */ - public numeric function $cacheCount(string category = "") { - if (Len(arguments.category)) { - local.rv = StructCount(application.wheels.cache[arguments.category]); - } else { - local.rv = 0; - for (local.key in application.wheels.cache) { - local.rv += StructCount(application.wheels.cache[local.key]); - } - } - return local.rv; - } - - /** - * Internal function. - */ - public void function $clearCache(string category = "") { - if (Len(arguments.category)) { - StructClear(application.wheels.cache[arguments.category]); - } else { - StructClear(application.wheels.cache); - } - } - - // ====================================================================== - // FACTORY FUNCTIONS - // ====================================================================== - - /** - * Internal function. - */ - public any function $cachedModelClassExists(required string name) { - local.rv = false; - if (StructKeyExists(application.wheels.models, arguments.name)) { - local.rv = application.wheels.models[arguments.name]; - } - return local.rv; - } - - /** - * Internal function. - * - * Lock-free warm fast-path lookup used by `model()` to bypass - * `$doubleCheckedLock` and its `$invoke` reflective dispatch on cache - * hits. The full `StructKeyExists` chain guards early-bootstrap and - * post-`?reload=true` windows where `application.wheels.models` may - * not yet exist. Returns the cached class on hit, `false` on miss - * (callers fall through to the slow path). - */ - public any function $cachedModelLookup(required string name) { - if ( - StructKeyExists(application, "wheels") - && StructKeyExists(application.wheels, "models") - && StructKeyExists(application.wheels.models, arguments.name) - ) { - return application.wheels.models[arguments.name]; - } - return false; - } - - /** - * Internal function. - */ - public any function $cachedControllerClassExists(required string name) { - local.rv = false; - if (StructKeyExists(application.wheels.controllers, arguments.name)) { - local.rv = application.wheels.controllers[arguments.name]; - } - return local.rv; - } - - /** - * Internal function. - * - * Lock-free warm fast-path lookup used by `controller()`. Same - * shape and bootstrap guards as `$cachedModelLookup`. - */ - public any function $cachedControllerLookup(required string name) { - if ( - StructKeyExists(application, "wheels") - && StructKeyExists(application.wheels, "controllers") - && StructKeyExists(application.wheels.controllers, arguments.name) - ) { - return application.wheels.controllers[arguments.name]; - } - return false; - } - - /** - * Internal function. - */ - public any function $createObjectFromRoot(required string path, required string fileName, required string method) { - local.method = arguments.method; - local.component = ListChangeDelims(arguments.path, ".", "/") & "." & ListChangeDelims(arguments.fileName, ".", "/"); - local.argumentCollection = arguments; - if (local.method EQ 'init') { - local.rv = application.wheelsdi.getInstance(name = "#local.component#", initArguments = local.argumentCollection); - } else { - local.instance = application.wheelsdi.getInstance(name = "#local.component#"); - local.rv = Invoke(local.instance, local.method, local.argumentCollection); - } - return local.rv; - } - - /** - * Internal function. - */ - public void function $debugPoint(required string name) { - if (!StructKeyExists(request.wheels, "execution")) { - request.wheels.execution = {}; - } - local.nameArray = ListToArray(arguments.name); - local.iEnd = ArrayLen(local.nameArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.item = local.nameArray[local.i]; - if (StructKeyExists(request.wheels.execution, local.item)) { - request.wheels.execution[local.item] = GetTickCount() - request.wheels.execution[local.item]; - } else { - request.wheels.execution[local.item] = GetTickCount(); - } - } - } - - /** - * Internal function. - */ - public any function $fileExistsNoCase(required string absolutePath) { - local.appKey = $appKey(); - // return false by default when the file does not exist in the directory - local.rv = false; - // break up the full path string in the path name only and the file name only - local.path = GetDirectoryFromPath(arguments.absolutePath); - local.file = Replace(arguments.absolutePath, local.path, ""); - // get all existing files in the directory and place them in a list in application scope - local.pathHash = Hash(local.path); - if (!StructKeyExists(application[local.appKey].directoryFiles, local.pathHash)) { - local.dirInfo = $directory(directory = local.path); - application[local.appKey].directoryFiles[local.pathHash] = ValueList(local.dirInfo.name); - } - local.fileList = application[local.appKey].directoryFiles[local.pathHash]; - // loop through the file list and return the file name if exists regardless of case (the == operator is case insensitive) - local.fileArray = ListToArray(local.fileList); - local.iEnd = ArrayLen(local.fileArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.foundFile = local.fileArray[local.i]; - if (local.foundFile == local.file) { - local.rv = local.foundFile; - break; - } - } - return local.rv; - } - - /** - * Internal function. - */ - public string function $objectFileName(required string name, required string objectPath, required string type) { - // by default we return Model or Controller so that the base component gets loaded - local.rv = capitalize(arguments.type); - - // we are going to memoize the full controller / model path in the - // existing / non-existing structs so we can have controllers / models - // in multiple places (structs give O(1) lookups and atomic writes where - // the comma lists used previously were O(n) scans per materialized object - // and lost entries to unlocked concurrent ListAppend calls) - // - // The name coming into $objectFileName could have dot notation due to - // nested controllers so we need to change delims here on the name - local.fullObjectPath = arguments.objectPath & "/" & ListChangeDelims(arguments.name, '/', '.'); - - if ( - !StructKeyExists(application.wheels.existingObjectFiles, local.fullObjectPath) - && !StructKeyExists(application.wheels.nonExistingObjectFiles, local.fullObjectPath) - ) { - // we have not yet checked if this file exists or not so let's do that - // here (the function below will return the file name with the correct - // case if it exists, false if not) - local.file = $fileExistsNoCase(ExpandPath(local.fullObjectPath) & ".cfc"); - - if (IsBoolean(local.file) && !local.file) { - // no file exists, let's store that if caching is on so we don't have to check it again - if (application.wheels.cacheFileChecking) { - application.wheels.nonExistingObjectFiles[local.fullObjectPath] = false; - } - } else { - // the file exists, let's store the proper case of the file if caching is turned on - local.file = SpanExcluding(local.file, "."); - if (application.wheels.cacheFileChecking) { - application.wheels.existingObjectFiles[local.fullObjectPath] = local.file; - } - } - } - - // if the file exists we return the file name in its proper case - if (StructKeyExists(application.wheels.existingObjectFiles, local.fullObjectPath)) { - local.file = application.wheels.existingObjectFiles[local.fullObjectPath]; - } - - // we've found a file so we'll need to send back the corrected name - // argument as it could have dot notation in it from the mapper - if (StructKeyExists(local, "file") and !IsBoolean(local.file)) { - local.rv = ListSetAt(arguments.name, ListLen(arguments.name, "."), local.file, "."); - } - - return local.rv; - } - - /** - * Internal function. - */ - public any function $createControllerClass( - required string name, - string controllerPaths = $get("controllerPath"), - string type = "controller" - ) { - // let's allow for multiple controller paths so that plugins can contain controllers - // the last path is the one we will instantiate the base controller on if the controller is not found on any of the paths - local.controllerPathsArray = ListToArray(arguments.controllerPaths); - local.iEnd = ArrayLen(local.controllerPathsArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.controllerPath = local.controllerPathsArray[local.i]; - local.fileName = $objectFileName(name = arguments.name, objectPath = local.controllerPath, type = arguments.type); - if (local.fileName != "Controller" || local.i == ArrayLen(local.controllerPathsArray)) { - application.wheels.controllers[arguments.name] = $createObjectFromRoot( - path = local.controllerPath, - fileName = local.fileName, - method = "$initControllerClass", - name = arguments.name - ); - - local.rv = application.wheels.controllers[arguments.name]; - break; - } - } - return local.rv; - } - - /** - * Internal function. - */ - public any function $createModelClass( - required string name, - string modelPaths = application.wheels.modelPath, - string type = "model" - ) { - // let's allow for multiple model paths so that plugins can contain models - // the last path is the one we will instantiate the base model on if the model is not found on any of the paths - local.modelPathsArray = ListToArray(arguments.modelPaths); - local.iEnd = ArrayLen(local.modelPathsArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.modelPath = local.modelPathsArray[local.i]; - local.fileName = $objectFileName(name = arguments.name, objectPath = local.modelPath, type = arguments.type); - if (local.fileName != arguments.type || local.i == ArrayLen(local.modelPathsArray)) { - application.wheels.models[arguments.name] = $createObjectFromRoot( - path = local.modelPath, - fileName = local.fileName, - method = "$initModelClass", - name = arguments.name - ); - local.rv = application.wheels.models[arguments.name]; - break; - } - } - return local.rv; - } - - /** - * Internal function. - */ - public void function $clearModelInitializationCache() { - StructClear(application.wheels.models); - } - - /** - * Internal function. - */ - public void function $clearControllerInitializationCache() { - StructClear(application.wheels.controllers); - } - - /** - * Creates and returns a controller object with your own custom name and params. - * Used primarily for testing purposes. - * - * [section: Global Helpers] - * [category: Miscellaneous Functions] - * - * @name Name of the controller to create. - * @params The params struct (combination of form and URL variables). - */ - public any function controller(required string name, struct params = {}) { - // Lock-free warm fast path: skip $doubleCheckedLock + $invoke - // reflective dispatch on cache hits (issue #2897, Stage 1). Returns - // the cached *class*; the params branch below still creates an - // instance when params is non-empty. - local.rv = $cachedControllerLookup(name = arguments.name); - if (IsBoolean(local.rv) && !local.rv) { - local.args = {}; - local.args.name = arguments.name; - local.rv = $doubleCheckedLock( - condition = "$cachedControllerClassExists", - conditionArgs = local.args, - execute = "$createControllerClass", - executeArgs = local.args, - name = "controllerLock#application.applicationName#" - ); - } - if (!StructIsEmpty(arguments.params)) { - local.rv = local.rv.$createControllerObject(arguments.params); - } - return local.rv; - } - - /** - * Returns a reference to the requested model so that class level methods can be called on it. - * - * [section: Global Helpers] - * [category: Miscellaneous Functions] - * - * @name Name of the model to get a reference to. - */ - public any function model(required string name) { - // Lock-free warm fast path: skip $doubleCheckedLock + $invoke - // reflective dispatch on cache hits (issue #2897, Stage 1). - local.rv = $cachedModelLookup(name = arguments.name); - if (IsBoolean(local.rv) && !local.rv) { - return $doubleCheckedLock( - condition = "$cachedModelClassExists", - conditionArgs = arguments, - execute = "$createModelClass", - executeArgs = arguments, - name = "modelLock#application.applicationName#" - ); - } - return local.rv; - } - - /** - * Resolve a DI-registered service by name. - * - * [section: Global Helpers] - * [category: Miscellaneous Functions] - * - * @name The registered service name to resolve. - */ - public any function service(required string name) { - if (!IsDefined("application.wheelsdi")) { - Throw( - type = "Wheels.DI.NotInitialized", - message = "The DI container has not been initialized. Ensure your application has started properly." - ); - } - if (!application.wheelsdi.containsInstance(arguments.name)) { - Throw( - type = "Wheels.DI.ServiceNotFound", - message = "No service registered with the name '#arguments.name#'. Check your config/services.cfm registrations." - ); - } - return application.wheelsdi.getInstance(arguments.name); - } - - /** - * Return a reference to the DI container for direct configuration. - * - * [section: Global Helpers] - * [category: Miscellaneous Functions] - */ - public any function injector() { - if (!IsDefined("application.wheelsdi")) { - Throw( - type = "Wheels.DI.NotInitialized", - message = "The DI container has not been initialized. Ensure your application has started properly." - ); - } - return application.wheelsdi; - } - - // ====================================================================== - // CHANNEL / PUB-SUB FUNCTIONS - // ====================================================================== - - /** - * Publish an event to a channel. - * Delegates to the in-memory Channel engine or the DatabaseAdapter - * depending on the adapter argument (or the global channelAdapter setting). - * - * Can be called from controllers, models, jobs, or anywhere with access - * to global helpers. - * - * [section: Global Helpers] - * [category: Channel Functions] - * - * @channel The channel name to publish to (e.g. "user.42"). - * @event The event type (e.g. "notification", "update"). - * @data The event data as a string (typically JSON). - * @adapter Adapter to use: "memory" (default) or "database". - */ - public struct function publish( - required string channel, - required string event, - required string data, - string adapter = "" - ) { - local.engine = $getChannelEngine(arguments.adapter); - return local.engine.publish(channel = arguments.channel, event = arguments.event, data = arguments.data); - } - - /** - * Internal: Get or create the channel engine singleton for the given adapter type. - * Uses double-checked locking to ensure thread-safe lazy initialization. - * - * @adapter "memory" or "database". Defaults to application.wheels.channelAdapter or "memory". - */ - public any function $getChannelEngine(string adapter = "") { - // Resolve adapter type - if (!Len(arguments.adapter)) { - if (StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "channelAdapter")) { - local.adapterType = application.wheels.channelAdapter; - } else { - local.adapterType = "memory"; - } - } else { - local.adapterType = arguments.adapter; - } - - if (local.adapterType == "database") { - if (!StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "channelDatabaseEngine")) { - lock name="wheelsChannelDatabaseEngine" timeout="10" { - if (!StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "channelDatabaseEngine")) { - application.wheels.channelDatabaseEngine = CreateObject("component", "wheels.channel.DatabaseAdapter").init(); - } - } - } - return application.wheels.channelDatabaseEngine; - } - - // Default: memory adapter - if (!StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "channelEngine")) { - lock name="wheelsChannelEngine" timeout="10" { - if (!StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "channelEngine")) { - application.wheels.channelEngine = CreateObject("component", "wheels.Channel").init(); - } - } - } - return application.wheels.channelEngine; - } - - // ====================================================================== - // ROUTING FUNCTIONS - // ====================================================================== - - /** - * Internal function. - */ - public string function $routeVariables() { - return $findRoute(argumentCollection = arguments).foundvariables; - } - - /** - * Internal function. - */ - public struct function $findRoute() { - // Throw error if no route was found. - if (!StructKeyExists(application.wheels.namedRoutePositions, arguments.route)) { - $throwErrorOrShow404Page( - type = "Wheels.RouteNotFound", - message = "Could not find the `#arguments.route#` route.", - extendedInfo = "Make sure there is a route configured in your `config/routes.cfm` file named `#arguments.route#`." - ); - } - local.routePos = application.wheels.namedRoutePositions[arguments.route]; - if (Find(",", local.routePos)) { - // there are several routes with this name so we need to figure out which one to use by checking the passed in arguments - local.iEnd = ListLen(local.routePos); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.rv = application.wheels.routes[ListGetAt(local.routePos, local.i)]; - local.foundRoute = StructKeyExists(arguments, "method") && local.rv.methods == arguments.method; - local.jEnd = ListLen(local.rv.foundvariables); - for (local.j = 1; local.j <= local.jEnd; local.j++) { - local.variable = ListGetAt(local.rv.foundvariables, local.j); - if (!StructKeyExists(arguments, local.variable) || !Len(arguments[local.variable])) { - local.foundRoute = false; - } - } - if (local.foundRoute) { - break; - } - } - } else { - local.rv = application.wheels.routes[local.routePos]; - } - return local.rv; - } - - /** - * Internal function. - */ - public any function $constructParams( - required string params, - boolean encode = true, - boolean $encodeForHtmlAttribute = false, - string $URLRewriting = application.wheels.URLRewriting - ) { - // When rewriting is off we will already have "?controller=" etc in the url so we have to continue with an ampersand. - if (arguments.$URLRewriting == "Off") { - local.delim = "&"; - } else { - local.delim = "?"; - } - - local.rv = ""; - local.paramsArray = ListToArray(arguments.params, "&"); - local.iEnd = ArrayLen(local.paramsArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.params = ListToArray(local.paramsArray[local.i], "="); - local.name = local.params[1]; - if (arguments.encode && $get("encodeURLs")) { - local.name = EncodeForURL($canonicalize(local.name)); - if (arguments.$encodeForHtmlAttribute) { - local.name = EncodeForHTMLAttribute(local.name); - } - } - local.rv &= local.delim & local.name & "="; - local.delim = "&"; - if (ArrayLen(local.params) == 2) { - local.value = local.params[2]; - if (arguments.encode && $get("encodeURLs")) { - local.value = EncodeForURL($canonicalize(local.value)); - if (arguments.$encodeForHtmlAttribute) { - local.value = EncodeForHTMLAttribute(local.value); - } - } - - // Obfuscate the param if set globally and we're not processing cfid or cftoken (can't touch those). - // Wrap in double quotes because in Lucee we have to pass it in as a string otherwise leading zeros are stripped. - if (application.wheels.obfuscateUrls && !ListFindNoCase("cfid,cftoken", local.name)) { - local.value = obfuscateParam("#local.value#"); - } - - local.rv &= local.value; - } - } - return local.rv; - } - - /** - * Internal function. - */ - public string function $prependUrl(required string path, string host = "", string protocol = "", numeric port = 0) { - local.rv = arguments.path; - if (arguments.port != 0) { - // use the port that was passed in by the developer - local.rv = ":" & arguments.port & local.rv; - } else if (request.cgi.server_port != 80 && request.cgi.server_port != 443) { - // if the port currently in use is not 80 or 443 we set it explicitly in the URL - local.rv = ":" & request.cgi.server_port & local.rv; - } - if (Len(arguments.host)) { - local.rv = arguments.host & local.rv; - } else { - local.rv = request.cgi.server_name & local.rv; - } - if (Len(arguments.protocol)) { - local.rv = arguments.protocol & "://" & local.rv; - } else if (request.cgi.http_x_forwarded_proto == "https" || request.cgi.server_port_secure == "true") { - local.rv = "https://" & local.rv; - } else { - local.rv = "http://" & local.rv; - } - return local.rv; - } - - /** - * Internal function. - */ - public void function $loadRoutes() { - $simpleLock(name = "$mapperLoadRoutes", type = "exclusive", timeout = 5, execute = "$lockedLoadRoutes"); - } - - /** - * Internal function. - */ - public void function $lockedLoadRoutes() { - local.appKey = $appKey(); - // clear out the route info (including the static-route index so a reload - // can't serve stale first-write-wins entries from the previous route set) - ArrayClear(application[local.appKey].routes); - StructClear(application[local.appKey].namedRoutePositions); - if (StructKeyExists(application[local.appKey], "staticRoutes")) { - StructClear(application[local.appKey].staticRoutes); - } - // Drop the URLFor controller/action memo so cached lookups from the - // previous route set (including negative-cached misses) can't leak - // across a reload. `$addRoute` also clears the memo, but doing it - // here guarantees a freshly-reloaded app starts with an empty cache - // even before the first `$addRoute` call runs. - if (StructKeyExists(application[local.appKey], "urlForCache")) { - StructClear(application[local.appKey].urlForCache); - } - // load wheels internal gui routes - // TODO skip this if mode != development|testing? - $include(template = "/wheels/public/routes.cfm"); - // Browser-test fixture routes — opt-in, only mounted in testing/development. - // See `vendor/wheels/public/browser-fixtures/routes.cfm` and issues #2135, #2138. - // The fixture controllers live at `vendor/wheels/public/browser-fixtures/controllers/` - // and render their own views via explicit `$include`, so only `controllerPath` - // needs to be extended (viewPath is single-string and left alone). - if ( - StructKeyExists(application[local.appKey], "loadBrowserTestFixtures") - && application[local.appKey].loadBrowserTestFixtures - && StructKeyExists(application[local.appKey], "environment") - && ListFindNoCase("testing,development", application[local.appKey].environment) - ) { - local.fixtureControllerPath = "/wheels/public/browser-fixtures/controllers"; - if (!ListFindNoCase(application[local.appKey].controllerPath, local.fixtureControllerPath)) { - application[local.appKey].controllerPath = ListAppend( - application[local.appKey].controllerPath, - local.fixtureControllerPath - ); - } - $include(template = "/wheels/public/browser-fixtures/routes.cfm"); - } - // load developer routes next - $include(template = "/config/routes.cfm"); - // set lookup info for the named routes - $setNamedRoutePositions(); - } - - /** - * Internal function. - */ - public void function $setNamedRoutePositions() { - local.appKey = $appKey(); - local.iEnd = ArrayLen(application[local.appKey].routes); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.route = application[local.appKey].routes[local.i]; - if (StructKeyExists(local.route, "name") && Len(local.route.name)) { - if (!StructKeyExists(application[local.appKey].namedRoutePositions, local.route.name)) { - application[local.appKey].namedRoutePositions[local.route.name] = ""; - } - application[local.appKey].namedRoutePositions[local.route.name] = ListAppend( - application[local.appKey].namedRoutePositions[local.route.name], - local.i - ); - } - } - } - - /** - * Creates an internal URL based on supplied arguments. - * - * [section: Global Helpers] - * [category: Miscellaneous Functions] - * - * @route Name of a route that you have configured in `config/routes.cfm`. - * @controller Name of the controller to include in the URL. - * @action Name of the action to include in the URL. - * @key Key(s) to include in the URL. - * @params Any additional parameters to be set in the query string (example: `wheels=cool&x=y`). Please note that Wheels uses the `&` and `=` characters to split the parameters and encode them properly for you. However, if you need to pass in `&` or `=` as part of the value, then you need to encode them (and only them), example: `a=cats%26dogs%3Dtrouble!&b=1`. - * @anchor Sets an anchor name to be appended to the path. - * @onlyPath If `true`, returns only the relative URL (no protocol, host name or port). - * @host Set this to override the current host. - * @protocol Set this to override the current protocol. - * @port Set this to override the current port number. - * @encode Encode URL parameters using `EncodeForURL()`. Please note that this does not make the string safe for placement in HTML attributes, for that you need to wrap the result in `EncodeForHtmlAttribute()` or use `linkTo()`, `startFormTag()` etc instead. - */ - public string function URLFor( - string route = "", - string controller = "", - string action = "", - any key = "", - string params = "", - string anchor = "", - boolean onlyPath, - string host, - string protocol, - numeric port, - boolean encode, - boolean $encodeForHtmlAttribute = false, - string $URLRewriting = application.wheels.URLRewriting - ) { - $args(name = "URLFor", args = arguments); - local.coreVariables = "controller,action,key,format"; - local.params = {}; - if (StructKeyExists(variables, "params")) { - StructAppend(local.params, variables.params); - } - - // Throw error if host or protocol are passed with onlyPath=true. - local.hostOrProtocolNotEmpty = Len(arguments.host) || Len(arguments.protocol); - if (application.wheels.showErrorInformation && arguments.onlyPath && local.hostOrProtocolNotEmpty) { - Throw( - type = "Wheels.IncorrectArguments", - message = "Can't use the `host` or `protocol` arguments when `onlyPath` is `true`.", - extendedInfo = "Set `onlyPath` to `false` so that `linkTo` will create absolute URLs and thus allowing you to set the `host` and `protocol` on the link." - ); - } - - // Look up actual route paths instead of providing default Wheels path generation. - // Loop over all routes to find matching one, break the loop on first match. - // The (controller, action) → route-name memo lives in application scope and - // negative-caches misses (empty string sentinel) so wildcard-`[controller]` - // apps — where `$addRoute` strips the `controller` key, guaranteeing no - // match — don't re-scan the route table for every link helper. The cache - // is invalidated by `$addRoute` and `$lockedLoadRoutes`. - if (!Len(arguments.route) && Len(arguments.action)) { - if (!Len(arguments.controller)) { - arguments.controller = local.params.controller; - } - local.appKey = $appKey(); - if (!StructKeyExists(application[local.appKey], "urlForCache")) { - application[local.appKey].urlForCache = {}; - } - local.cache = application[local.appKey].urlForCache; - local.key = arguments.controller & "##" & arguments.action; - if (!StructKeyExists(local.cache, local.key)) { - local.found = ""; - local.iEnd = ArrayLen(application[local.appKey].routes); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.route = application[local.appKey].routes[local.i]; - local.controllerMatch = StructKeyExists(local.route, "controller") && local.route.controller == arguments.controller; - local.actionMatch = StructKeyExists(local.route, "action") && local.route.action == arguments.action; - if (local.controllerMatch && local.actionMatch) { - local.found = local.route.name; - break; - } - } - local.cache[local.key] = local.found; - } - if (Len(local.cache[local.key])) { - arguments.route = local.cache[local.key]; - } - } - - // Start building the URL to return by setting the sub folder path and script name portion. - // Script name index.cfm will be removed later if applicable (e.g. when URL rewriting is on). - local.rv = application.wheels.webPath & ListLast(request.cgi.script_name, "/"); - - // Look up route pattern to use and add it to the URL to return. - // Either from a passed in route or the Wheels default one. - // For the Wheels default we set the controller and action arguments to what's in the params struct. - if (Len(arguments.route)) { - local.route = $findRoute(argumentCollection = arguments); - local.foundVariables = local.route.foundvariables; - - if (arguments.$URLRewriting neq "Off") { - local.rv &= local.route.pattern; - } else { - // Always include core variables when not rewriting - local.foundVariables &= "," & local.coreVariables; - local.rv &= "?controller=[controller]&action=[action]&key=[key]&format=[format]"; - } - } else { - local.route = {}; - local.foundVariables = local.coreVariables; - local.rv &= "?controller=[controller]&action=[action]&key=[key]&format=[format]"; - } - - // Shared fallback logic for controller/action - if (StructKeyExists(local, "params")) { - // Handle action - if (!Len(arguments.action)) { - if (StructKeyExists(local.route, "action")) { - arguments.action = local.route.action; - } else if (Len(arguments.controller)) { - arguments.action = "index"; - } else if (StructKeyExists(local.params, "action")) { - arguments.action = local.params.action; - } - } - - // Handle controller - if (!Len(arguments.controller)) { - if (StructKeyExists(local.route, "controller")) { - arguments.controller = local.route.controller; - } else if (StructKeyExists(local.params, "controller")) { - arguments.controller = local.params.controller; - } - } - } - - // Replace each params variable with the correct value. - for (local.i = 1; local.i <= ListLen(local.foundVariables); local.i++) { - local.property = ListGetAt(local.foundVariables, local.i); - local.reg = "\[\*?#local.property#\]"; - - // Read necessary variables from different sources. - if (StructKeyExists(arguments, local.property) && Len(arguments[local.property])) { - local.value = arguments[local.property]; - } else if (StructKeyExists(local.route, local.property)) { - local.value = local.route[local.property]; - } else if (Len(arguments.route) && arguments.$URLRewriting != "Off") { - Throw( - type = "Wheels.IncorrectRoutingArguments", - message = "Incorrect Arguments", - extendedInfo = "The route chosen by Wheels `#local.route.name#` requires the argument `#local.property#`. Pass the argument `#local.property#` or change your routes to reflect the proper variables needed." - ); - } else { - continue; - } - - // If value is a model object, get its key value. - if (IsObject(local.value)) { - local.value = local.value.key(); - } - - // Any value we find from above, URL encode it here. - if (arguments.encode && $get("encodeURLs")) { - local.value = EncodeForURL($canonicalize(local.value)); - if (arguments.$encodeForHtmlAttribute) { - local.value = EncodeForHTMLAttribute(local.value); - } - } - - // If property is not in pattern, store it in the params argument. - if (!ReFind(local.reg, local.rv)) { - if (!ListFindNoCase(local.coreVariables, local.property)) { - arguments.params = ListAppend(arguments.params, "#local.property#=#local.value#", "&"); - } - continue; - } - - // Transform value before setting it in pattern. - if (local.property == "controller" || local.property == "action") { - local.value = hyphenize(local.value); - } else if (application.wheels.obfuscateUrls) { - local.value = obfuscateParam(local.value); - } - local.rv = ReReplace(local.rv, local.reg, local.value); - } - - // Clean up unused keys in pattern. - local.rv = ReReplace(local.rv, "((&|\?)\w+=|\/|\.)\[\*?\w+\]", "", "ALL"); - - // When URL rewriting is on (or partially) we replace the "?controller="" stuff in the URL with just "/". - if (arguments.$URLRewriting != "Off") { - local.rv = Replace(local.rv, "?controller=", "/"); - local.rv = Replace(local.rv, "&action=", "/"); - local.rv = Replace(local.rv, "&key=", "/"); - } - - // When URL rewriting is on we remove the rewrite file name (e.g. index.cfm) from the URL so it doesn't show. - // Also get rid of the double "/" that this removal typically causes. - if (arguments.$URLRewriting == "On") { - local.rv = Replace(local.rv, application.wheels.rewriteFile, ""); - local.rv = Replace(local.rv, "//", "/"); - } - - // Add params to the URL when supplied. - if (Len(arguments.params)) { - local.rv &= $constructParams( - params = arguments.params, - encode = arguments.encode, - $encodeForHtmlAttribute = arguments.$encodeForHtmlAttribute, - $URLRewriting = arguments.$URLRewriting - ); - } - - // Add an anchor to the the URL when supplied. - if (Len(arguments.anchor)) { - local.rv &= "##" & arguments.anchor; - } - - // Prepend the full URL if directed. - if (!arguments.onlyPath) { - local.rv = $prependUrl(path = local.rv, argumentCollection = arguments); - } - - return local.rv; - } - - /** - * Returns the mapper object used to configure your application's routes. Usually you will use this method in `config/routes.cfm` to start chaining route mapping methods like `resources`, `namespace`, etc. - * - * [section: Configuration] - * [category: Routing] - * - * @restful Whether to turn on RESTful routing or not. Not recommended to set. Will probably be removed in a future version of wheels, as RESTful routes are the default. - * @methods If not RESTful, then specify allowed routes. Not recommended to set. Will probably be removed in a future version of wheels, as RESTful routes are the default. - * @mapFormat This is useful for providing formats via URL like `json`, `xml`, `pdf`, etc. Set to false to disable automatic .[format] generation for resource based routes - */ - public struct function mapper(boolean restful = true, boolean methods = arguments.restful, boolean mapFormat = true) { - return application[$appKey()].mapper.$draw(argumentCollection = arguments); - } - - // ====================================================================== - // TEXT FUNCTIONS - // ====================================================================== - - /** - * Internal function. - */ - public string function $singularizeOrPluralize( - required string text, - required string which, - numeric count = -1, - boolean returnCount = true - ) { - // by default we pluralize/singularize the entire string - local.text = arguments.text; - - // keep track of the success of any rule matches - local.ruleMatched = false; - - // when count is 1 we don't need to pluralize at all so just set the return value to the input string - local.rv = local.text; - - if (arguments.count != 1) { - if (ReFind("[A-Z]", local.text)) { - // only pluralize/singularize the last part of a camelCased variable (e.g. in "websiteStatusUpdate" we only change the "update" part) - // also set a variable with the unchanged part of the string (to be prepended before returning final result) - local.upperCasePos = ReFind("[A-Z]", Reverse(local.text)); - local.prepend = Mid(local.text, 1, Len(local.text) - local.upperCasePos); - local.text = Reverse(Mid(Reverse(local.text), 1, local.upperCasePos)); - } - - // Get global settings for uncountable and irregular words. - // For the irregular ones we need to convert them from a struct to a list. - local.uncountables = $listClean($get("uncountables")); - local.irregulars = ""; - local.words = $get("irregulars"); - for (local.word in local.words) { - local.irregulars = ListAppend(local.irregulars, LCase(local.word)); - local.irregulars = ListAppend(local.irregulars, local.words[local.word]); - } - - if (ListFindNoCase(local.uncountables, local.text)) { - local.rv = local.text; - local.ruleMatched = true; - } else if (ListFindNoCase(local.irregulars, local.text)) { - local.pos = ListFindNoCase(local.irregulars, local.text); - if (arguments.which == "singularize" && local.pos % 2 == 0) { - local.rv = ListGetAt(local.irregulars, local.pos - 1); - } else if (arguments.which == "pluralize" && local.pos % 2 != 0) { - local.rv = ListGetAt(local.irregulars, local.pos + 1); - } else { - local.rv = local.text; - } - local.ruleMatched = true; - } else { - if (arguments.which == "pluralize") { - local.ruleList = "(quiz)$,\1zes,^(ox)$,\1en,([m|l])ouse$,\1ice,(matr|vert|ind)ix|ex$,\1ices,(x|ch|ss|sh)$,\1es,([^aeiouy]|qu)y$,\1ies,(hive)$,\1s,(?:([^f])fe|([lr])f)$,\1\2ves,sis$,ses,([ti])um$,\1a,(buffal|tomat|potat|volcan|her)o$,\1oes,(bu)s$,\1ses,(alias|status)$,\1es,(octop|vir)us$,\1i,(ax|test)is$,\1es,s$,s,$,s"; - } else if (arguments.which == "singularize") { - local.ruleList = "(quiz)zes$,\1,(matr)ices$,\1ix,(vert|ind)ices$,\1ex,^(ox)en,\1,(alias|status)es$,\1,([octop|vir])i$,\1us,(cris|ax|test)es$,\1is,(shoe)s$,\1,(o)es$,\1,(bus)es$,\1,([m|l])ice$,\1ouse,(x|ch|ss|sh)es$,\1,(m)ovies$,\1ovie,(s)eries$,\1eries,([^aeiouy]|qu)ies$,\1y,([lr])ves$,\1f,(tive)s$,\1,(hive)s$,\1,([^f])ves$,\1fe,(^analy)ses$,\1sis,((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$,\1\2sis,([ti])a$,\1um,(n)ews$,\1ews,(.*)?ss$,\1ss,s$,#Chr(7)#"; - } - local.rules = ArrayNew(2); - local.count = 1; - local.iEnd = ListLen(local.ruleList); - for (local.i = 1; local.i <= local.iEnd; local.i = local.i + 2) { - local.rules[local.count][1] = ListGetAt(local.ruleList, local.i); - local.rules[local.count][2] = ListGetAt(local.ruleList, local.i + 1); - local.count = local.count + 1; - } - local.iEnd = ArrayLen(local.rules); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - if (ReFindNoCase(local.rules[local.i][1], local.text)) { - local.rv = ReReplaceNoCase(local.text, local.rules[local.i][1], local.rules[local.i][2]); - local.ruleMatched = true; - break; - } - } - local.rv = Replace(local.rv, Chr(7), "", "all"); - } - - // this was a camelCased string and we need to prepend the unchanged part to the result - if (StructKeyExists(local, "prepend") && local.ruleMatched) { - local.rv = local.prepend & local.rv; - } - } - - // return the count number in the string (e.g. "5 sites" instead of just "sites") - if (arguments.returnCount && arguments.count != -1) { - local.rv = LsNumberFormat(arguments.count) & " " & local.rv; - } - return local.rv; - } - - /** - * Capitalizes the first character of the supplied string. - * - * [section: Global Helpers] - * [category: String Functions] - * - * @text String to capitalize. - */ - public string function capitalize(required string text) { - local.rv = arguments.text; - if (Len(local.rv)) { - local.rv = UCase(Left(local.rv, 1)) & Mid(local.rv, 2, Len(local.rv) - 1); - } - return local.rv; - } - - /** - * Returns readable text by capitalizing and converting camel casing to multiple words. - * - * [section: Global Helpers] - * [category: String Functions] - * - * @text Text to humanize. - * @except A list of strings (space separated) to replace within the output. - * - */ - public string function humanize(required string text, string except = "") { - // add a space before every capitalized word - local.rv = ReReplace(arguments.text, "([[:upper:]])", " \1", "all"); - - // remove space after punctuation chars - local.rv = ReReplace(local.rv, "([[:punct:]])([[:space:]])", "\1", "all"); - - // fix abbreviations so they form a word again (example: aURLVariable) - local.rv = ReReplace(local.rv, "([[:upper:]]) ([[:upper:]])(?:\s|\b)", "\1\2", "all"); - local.rv = ReReplace(local.rv, "([[:upper:]])([[:upper:]])([[:lower:]])", "\1\2 \3", "all"); - - if (Len(arguments.except)) { - local.exceptKeysArray = ListToArray(arguments.except, " "); - local.iEnd = ArrayLen(local.exceptKeysArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.item = local.exceptKeysArray[local.i]; - local.rv = ReReplaceNoCase(local.rv, "#local.item#(?:\b)", "#local.item#", "all"); - } - } - - // support multiple word input by stripping out all double spaces created - local.rv = Replace(local.rv, " ", " ", "all"); - - // capitalize the first letter and trim final result (which removes the leading space that happens if the string starts with an upper case character) - local.rv = Trim(capitalize(local.rv)); - return local.rv; - } - - /** - * Returns the plural form of the passed in word. Can also pluralize a word based on a value passed to the `count` argument. Wheels stores a list of words that are the same in both singular and plural form (e.g. "equipment", "information") and words that don't follow the regular pluralization rules (e.g. "child" / "children", "foot" / "feet"). Use `get("uncountables")` / `set("uncountables", newList)` and `get("irregulars")` / `set("irregulars", newList)` to modify them to suit your needs. - * - * [section: Global Helpers] - * [category: String Functions] - * - * @word The word to pluralize. - * @count Pluralization will occur when this value is not 1. - * @returnCount Will return count prepended to the pluralization when true and count is not -1. - */ - public string function pluralize(required string word, numeric count = "-1", boolean returnCount = "true") { - return $singularizeOrPluralize( - count = arguments.count, - returnCount = arguments.returnCount, - text = arguments.word, - which = "pluralize" - ); - } - - /** - * Returns the singular form of the passed in word. - * - * [section: Global Helpers] - * [category: String Functions] - * - * @word The word to singularize. - */ - public string function singularize(required string word) { - return $singularizeOrPluralize(text = arguments.word, which = "singularize"); - } - - /** - * Converts camelCase strings to lowercase strings with hyphens as word delimiters instead. Example: myVariable becomes my-variable. - * - * [section: Global Helpers] - * [category: String Functions] - * - * @string The string to hyphenize. - */ - public string function hyphenize(required string string) { - local.rv = ReReplace(arguments.string, "([A-Z][a-z])", "-\l\1", "all"); - local.rv = ReReplace(local.rv, "([a-z])([A-Z])", "\1-\l\2", "all"); - local.rv = ReReplace(local.rv, "^-", "", "one"); - local.rv = LCase(local.rv); - return local.rv; - } - - /** - * Capitalizes all words in the text to create a nicer looking title. - * - * [section: Global Helpers] - * [category: String Functions] - * - * @word The text to turn into a title. - */ - public string function titleize(required string word) { - local.rv = ""; - local.iEnd = ListLen(arguments.word, " "); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.rv = ListAppend(local.rv, capitalize(ListGetAt(arguments.word, local.i, " ")), " "); - } - return local.rv; - } - - /** - * Truncates text to the specified length and replaces the last characters with the specified truncate string (which defaults to "..."). - * - * [section: Global Helpers] - * [category: String Functions] - * - * @text The text to truncate. - * @length Length to truncate the text to. - * @truncateString String to replace the last characters with. - */ - public string function truncate(required string text, numeric length, string truncateString) { - $args(name = "truncate", args = arguments); - if (Len(arguments.text) > arguments.length) { - local.rv = Left(arguments.text, arguments.length - Len(arguments.truncateString)) & arguments.truncateString; - } else { - local.rv = arguments.text; - } - return local.rv; - } - - /** - * Truncates text to the specified length of words and replaces the remaining characters with the specified truncate string (which defaults to "..."). - * - * [section: Global Helpers] - * [category: String Functions] - * - * @text The text to truncate. - * @length Number of words to truncate the text to. - * @truncateString String to replace the last characters with. - */ - public string function wordTruncate(required string text, numeric length, string truncateString) { - $args(name = "wordTruncate", args = arguments); - local.words = ListToArray(arguments.text, " ", false); - - // When there are fewer (or same) words in the string than the number to be truncated we can just return it unchanged. - if (ArrayLen(local.words) <= arguments.length) { - return arguments.text; - } - - local.rv = ""; - local.iEnd = arguments.length; - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.rv = ListAppend(local.rv, local.words[local.i], " "); - } - local.rv &= arguments.truncateString; - return local.rv; - } - - /** - * Extracts an excerpt from text that matches the first instance of a given phrase. - * - * [section: Global Helpers] - * [category: String Functions] - * - * @text The text to extract an excerpt from. - * @phrase The phrase to extract. - * @radius Number of characters to extract surrounding the phrase. - * @excerptString String to replace first and / or last characters with. - */ - public string function excerpt(required string text, required string phrase, numeric radius, string excerptString) { - $args(name = "excerpt", args = arguments); - local.pos = FindNoCase(arguments.phrase, arguments.text, 1); - - // Return an empty value if the text wasn't found at all. - if (!local.pos) { - return ""; - } - - // Set start info based on whether the excerpt text found, including its radius, comes before the start of the string. - if ((local.pos - arguments.radius) <= 1) { - local.startPos = 1; - local.truncateStart = ""; - } else { - local.startPos = local.pos - arguments.radius; - local.truncateStart = arguments.excerptString; - } - - // Set end info based on whether the excerpt text found, including its radius, comes after the end of the string. - if ((local.pos + Len(arguments.phrase) + arguments.radius) > Len(arguments.text)) { - local.endPos = Len(arguments.text); - local.truncateEnd = ""; - } else { - local.endPos = local.pos + arguments.radius; - local.truncateEnd = arguments.excerptString; - } - - local.len = (local.endPos + Len(arguments.phrase)) - local.startPos; - local.mid = Mid(arguments.text, local.startPos, local.len); - local.rv = local.truncateStart & local.mid & local.truncateEnd; - return local.rv; - } - - // ====================================================================== - // DATETIME FUNCTIONS - // ====================================================================== - - /** - * Internal function. - */ - public string function $timestamp(string timeStampMode = application.wheels.timeStampMode) { - switch (arguments.timeStampMode) { - case "utc": - local.rv = DateConvert("local2Utc", Now()); - break; - case "local": - local.rv = Now(); - break; - case "epoch": - local.rv = Now().getTime(); - break; - default: - Throw(type = "Wheels.InvalidTimeStampMode", message = "Timestamp mode #arguments.timeStampMode# is invalid"); - } - - // Ensure adapterName is set (may not be if no model has been called yet) - if (!StructKeyExists(application[$appKey()], "adapterName")) { - local.dbType = $getDBType(); - $set(adapterName = "#local.dbType#Model"); - } - - // SQLite stores datetimes as TEXT. Format as a clean ISO-8601 string - // (no surrounding quotes — those are SQL-literal syntax, not data) so - // the value lands in the TEXT column verbatim and round-trips through - // IsDate/DateFormat without quote-stripping. - if ($get("adapterName") == "SQLiteModel") { - if (IsDate(local.rv)) { - local.rv = DateFormat(local.rv, "yyyy-mm-dd") & " " & TimeFormat(local.rv, "HH:mm:ss"); - } - } - - return local.rv; - } - - /** - * Pass in two dates to this method, and it will return a string describing the difference between them. - * - * [section: Global Helpers] - * [category: Date Functions] - * - * @fromTime Date to compare from. - * @toTime Date to compare to. - * @includeSeconds Whether or not to include the number of seconds in the returned string. - */ - public string function distanceOfTimeInWords(required date fromTime, required date toTime, boolean includeSeconds) { - $args(name = "distanceOfTimeInWords", args = arguments); - local.minuteDiff = DateDiff("n", arguments.fromTime, arguments.toTime); - local.secondDiff = DateDiff("s", arguments.fromTime, arguments.toTime); - local.hours = 0; - local.days = 0; - local.rv = ""; - if (local.minuteDiff <= 1) { - if (local.secondDiff < 60) { - local.rv = "less than a minute"; - } else { - local.rv = "1 minute"; - } - if (arguments.includeSeconds) { - if (local.secondDiff < 5) { - local.rv = "less than 5 seconds"; - } else if (local.secondDiff < 10) { - local.rv = "less than 10 seconds"; - } else if (local.secondDiff < 20) { - local.rv = "less than 20 seconds"; - } else if (local.secondDiff < 40) { - local.rv = "half a minute"; - } - } - } else if (local.minuteDiff < 45) { - local.rv = local.minuteDiff & " minutes"; - } else if (local.minuteDiff < 90) { - local.rv = "about 1 hour"; - } else if (local.minuteDiff < 1440) { - local.hours = Ceiling(local.minuteDiff / 60); - local.rv = "about " & local.hours & " hours"; - } else if (local.minuteDiff < 2880) { - local.rv = "1 day"; - } else if (local.minuteDiff < 43200) { - local.days = Int(local.minuteDiff / 1440); - local.rv = local.days & " days"; - } else if (local.minuteDiff < 86400) { - local.rv = "about 1 month"; - } else if (local.minuteDiff < 525600) { - local.months = Int(local.minuteDiff / 43200); - local.rv = local.months & " months"; - } else if (local.minuteDiff < 657000) { - local.rv = "about 1 year"; - } else if (local.minuteDiff < 919800) { - local.rv = "over 1 year"; - } else if (local.minuteDiff < 1051200) { - local.rv = "almost 2 years"; - } else if (local.minuteDiff >= 1051200) { - local.years = Int(local.minuteDiff / 525600); - local.rv = "over " & local.years & " years"; - } - return local.rv; - } - - /** - * Returns a string describing the approximate time difference between the date passed in and the current date. - * - * [section: Global Helpers] - * [category: Date Functions] - * - * @fromTime Date to compare from. - * @includeSeconds Whether or not to include the number of seconds in the returned string. - * @toTime Date to compare to. - */ - public any function timeAgoInWords(required date fromTime, boolean includeSeconds, date toTime = Now()) { - $args(name = "timeAgoInWords", args = arguments); - return distanceOfTimeInWords(argumentCollection = arguments); - } - - /** - * Returns a string describing the approximate time difference between the current date and the date passed in. - * - * [section: Global Helpers] - * [category: Date Functions] - * - * @toTime Date to compare to. - * @includeSeconds Whether or not to include the number of seconds in the returned string. - * @fromTime Date to compare from. - */ - public string function timeUntilInWords(required date toTime, boolean includeSeconds, date fromTime = Now()) { - $args(name = "timeUntilInWords", args = arguments); - return distanceOfTimeInWords(argumentCollection = arguments); - } - - // ====================================================================== - // REQUEST FUNCTIONS - // ====================================================================== - - /** - * Internal function. - */ - public void function $initializeRequestScope() { - if (!StructKeyExists(request, "wheels")) { - request.wheels = {}; - request.wheels.params = {}; - request.wheels.cache = {}; - request.wheels.urlForCache = {}; - request.wheels.tickCountId = GetTickCount(); - - // Copy HTTP request data (contains content, headers, method and protocol). - // This makes internal testing easier since we can overwrite it temporarily from the test suite. - request.wheels.httpRequestData = GetHTTPRequestData(); - - // Create a structure to track the transaction status for all adapters. - request.wheels.transactions = {}; - } - } - - /** - * Get the status code (e.g. 200, 404 etc) of the response we're about to send. - */ - public string function $statusCode() { - if ($hasEngineAdapter()) { - return $engineAdapter().getStatusCode(); - } - // Fallback when adapter not yet initialized (e.g. error during startup) - if (StructKeyExists(server, "lucee") || StructKeyExists(server, "boxlang")) { - return GetPageContext().getResponse().getStatus(); - } - return GetPageContext() - .getFusionContext() - .getResponse() - .getStatus(); - } - - /** - * Gets the value of the content type header (blank string if it doesn't exist) of the response we're about to send. - */ - public string function $contentType() { - if ($hasEngineAdapter()) { - return $engineAdapter().getContentType(); - } - // Fallback when adapter not yet initialized - local.rv = ""; - if (StructKeyExists(server, "lucee")) { - local.response = GetPageContext().getResponse(); - } else if (StructKeyExists(server, "boxlang")) { - local.response = GetPageContext(); - } else { - local.response = GetPageContext().getFusionContext().getResponse(); - } - try { - if (StructKeyExists(server, "boxlang")) { - local.header = local.response.getRequest().getHeader("Content-Type"); - } else { - local.header = local.response.containsHeader("Content-Type") ? local.response.getHeader("Content-Type") : Javacast( - "null", - "" - ); - } - if (!IsNull(local.header)) { - local.rv = local.header; - } - } catch (any e) { - } - return local.rv; - } - - /** - * This copies all the variables Wheels needs from the CGI scope to the request scope. - */ - public struct function $cgiScope( - string keys = "request_method,http_x_requested_with,http_referer,server_name,path_info,script_name,query_string,remote_addr,server_port,server_port_secure,server_protocol,http_host,http_accept,content_type,http_x_rewrite_url,http_x_original_url,request_uri,redirect_url,http_x_forwarded_for,http_x_forwarded_proto", - struct scope = cgi - ) { - local.rv = {}; - local.keyArray = ListToArray(arguments.keys); - local.iEnd = ArrayLen(local.keyArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.item = local.keyArray[local.i]; - local.rv[local.item] = arguments.scope[local.item]; - } - - // fix path_info if it contains any characters that are not ascii (see issue 138) - if (StructKeyExists(arguments.scope, "unencoded_url") && Len(arguments.scope.unencoded_url)) { - local.requestUrl = UrlDecode(arguments.scope.unencoded_url); - } else if (IsSimpleValue(GetPageContext().getRequest().getRequestURL())) { - // remove protocol, domain, port etc from the url - local.requestUrl = "/" & ListDeleteAt( - ListDeleteAt(UrlDecode(GetPageContext().getRequest().getRequestURL()), 1, "/"), - 1, - "/" - ); - } - if (StructKeyExists(local, "requestUrl") && ReFind("[^\x00-\x80]", local.requestUrl)) { - // strip out the script_name and query_string leaving us with only the part of the string that should go in path_info - local.rv.path_info = Replace( - Replace(local.requestUrl, arguments.scope.script_name, ""), - "?" & UrlDecode(arguments.scope.query_string), - "" - ); - } - - // fixes IIS issue that returns a blank cgi.path_info - if (!Len(local.rv.path_info) && Right(local.rv.script_name, 10) == "/index.cfm") { - if (Len(local.rv.http_x_rewrite_url)) { - // IIS6 1/ IIRF (Ionics Isapi Rewrite Filter) - local.rv.path_info = ListFirst(local.rv.http_x_rewrite_url, "?"); - } else if (Len(local.rv.http_x_original_url)) { - // IIS7 rewrite default - local.rv.path_info = ListFirst(local.rv.http_x_original_url, "?"); - } else if (Len(local.rv.request_uri)) { - // Apache default - local.rv.path_info = ListFirst(local.rv.request_uri, "?"); - } else if (Len(local.rv.redirect_url)) { - // Apache fallback - local.rv.path_info = ListFirst(local.rv.redirect_url, "?"); - } - - // finally lets remove the index.cfm because some of the custom cgi variables don't bring it back - // like this it means at the root we are working with / instead of /index.cfm - if (Len(local.rv.path_info) >= 10 && Right(local.rv.path_info, 10) == "/index.cfm") { - // this will remove the index.cfm and the trailing slash - local.rv.path_info = Replace(local.rv.path_info, "/index.cfm", ""); - if (!Len(local.rv.path_info)) { - // add back the forward slash if path_info was "/index.cfm" - local.rv.path_info = "/"; - } - } - } - - // some web servers incorrectly place index.cfm in the path_info but since that should never be there we can safely remove it - if (Find("index.cfm/", local.rv.path_info)) { - Replace(local.rv.path_info, "index.cfm/", ""); - } - return local.rv; - } - - /** - * Internal function. Returns whether the application has opted into trusting `X-Forwarded-*` - * headers via `set(trustProxyHeaders=true)`. Guarded so it is safe to call on a cold start - * before `application.wheels` exists (resolves to `false`, i.e. do not trust). - */ - public boolean function $trustProxyHeaders() { - return StructKeyExists(application, "wheels") - && StructKeyExists(application.wheels, "trustProxyHeaders") - && IsBoolean(application.wheels.trustProxyHeaders) - && application.wheels.trustProxyHeaders; - } - - /** - * Internal function. Resolves the trusted client IP for security decisions. - * Returns `REMOTE_ADDR` (the socket address) unless `trustProxyHeaders` is enabled and - * `X-Forwarded-For` is non-empty, in which case the rightmost hop is used — that is the entry - * appended by the trusted proxy nearest the app; earlier entries are client-supplied and - * spoofable. For this to be safe the proxy must overwrite — never append to — the incoming - * header. - */ - public string function $trustedClientIp(string remoteAddr, string forwardedFor) { - if (!StructKeyExists(arguments, "remoteAddr")) { - arguments.remoteAddr = cgi.remote_addr; - } - if (!StructKeyExists(arguments, "forwardedFor")) { - arguments.forwardedFor = cgi.http_x_forwarded_for; - } - local.rv = Trim(arguments.remoteAddr); - if ($trustProxyHeaders() && Len(Trim(arguments.forwardedFor))) { - local.rv = Trim(ListLast(arguments.forwardedFor)); - } - return local.rv; - } - - /** - * Internal function. Returns whether the current client is exempt from maintenance mode. - * The exception list comes from config only (`set(ipExceptions="...")`). A list containing - * letters is matched against the user agent (legacy behavior preserved verbatim); otherwise - * it is matched against the trusted client IP. - */ - public boolean function $maintenanceModeExempt( - required string exceptions, - required string userAgent, - required string clientIp - ) { - if (!Len(arguments.exceptions)) { - return false; - } - if (ReFindNoCase("[a-z]", arguments.exceptions)) { - return ListFindNoCase(arguments.exceptions, arguments.userAgent) > 0; - } - return ListFind(arguments.exceptions, arguments.clientIp) > 0; - } - - /** - * Internal function. Derives `webPath`, `rootPath`, `rootcomponentPath`, - * and `wheelsComponentPath` from either an explicit URL `subpath` - * (issue #2968 — subfolder installs where `cgi.script_name` does not - * reflect the public mount) or, when no subpath is given, the existing - * `cgi.script_name` derivation. Returning a struct keeps the helper - * pure so it can be unit-tested in isolation. - */ - public struct function $resolveFrameworkPaths(required string scriptName, string subpath = "") { - local.rv = {}; - local.normalized = Trim(arguments.subpath); - if (Len(local.normalized) && Left(local.normalized, 1) != "/") { - local.normalized = "/" & local.normalized; - } - // Strip trailing slash(es) without falling through to Left(str, 0), - // which crashes Lucee 7 (see CLAUDE.md § "Cross-Engine Invariants"). - while (Len(local.normalized) > 1 && Right(local.normalized, 1) == "/") { - local.normalized = Left(local.normalized, Len(local.normalized) - 1); - } - if (Len(local.normalized)) { - local.rv.webPath = local.normalized == "/" ? "/" : local.normalized & "/"; - } else { - local.rv.webPath = Replace( - arguments.scriptName, - Reverse(SpanExcluding(Reverse(arguments.scriptName), "/")), - "" - ); - } - local.rv.rootPath = "/" & ListChangeDelims(local.rv.webPath, "/", "/"); - local.rv.rootcomponentPath = ListChangeDelims(local.rv.webPath, ".", "/"); - local.rv.wheelsComponentPath = ListAppend(local.rv.rootcomponentPath, "wheels", "."); - return local.rv; - } - - /** - * Internal function. - */ - public void function $abortInvalidRequest() { - local.applicationPath = Replace(GetCurrentTemplatePath(), "\", "/", "all"); - local.callingPath = Replace(GetBaseTemplatePath(), "\", "/", "all"); - if ( - !(GetFileFromPath(local.callingPath) == "runner.cfm") - && - ListLen(local.callingPath, "/") > ListLen(local.applicationPath, "/") - ) { - if (StructKeyExists(application, "wheels")) { - if (StructKeyExists(application.wheels, "showErrorInformation") && !application.wheels.showErrorInformation) { - $header(statusCode = 404); - } - if (StructKeyExists(application.wheels, "eventPath")) { - $includeAndOutput(template = "#application.wheels.eventPath#/onmissingtemplate.cfm"); - } - } - $header(statusCode = 404); - abort; - } - } - - /** - * Throw a developer friendly Wheels error if set (typically in development mode). - * Otherwise show the 404 page for end users (typically in production mode). - */ - public void function $throwErrorOrShow404Page(required string type, required string message, string extendedInfo = "") { - $header(statusCode = 404); - if ($get("showErrorInformation")) { - Throw(type = arguments.type, message = arguments.message, extendedInfo = arguments.extendedInfo); - } else { - local.template = $get("eventPath") & "/onmissingtemplate.cfm"; - $includeAndOutput(template = local.template); - abort; - } - } - - /** - * Returns the request timeout value in seconds. - * Must be safe to call during onError before application.wheels is initialized. - */ - public numeric function $getRequestTimeout() { - if ($hasEngineAdapter()) { - return $engineAdapter().getRequestTimeout(); - } - // Fallback when adapter not yet initialized (e.g. error during startup) - if (StructKeyExists(server, "boxlang")) { - return 10000; - } else if (StructKeyExists(server, "lucee")) { - return (GetPageContext().getRequestTimeout() / 1000); - } else { - return CreateObject("java", "coldfusion.runtime.RequestMonitor").GetRequestTimeout(); - } - } - - /** - * Returns the engine adapter instance for centralized cross-engine behavior. - * Checks both application.wheels (post-init) and application.$wheels (during init). - */ - public any function $engineAdapter() { - if ( - StructKeyExists(application, "wheels") && IsStruct(application.wheels) && StructKeyExists( - application.wheels, - "engineAdapter" - ) - ) { - return application.wheels.engineAdapter; - } - if ( - StructKeyExists(application, "$wheels") && IsStruct(application.$wheels) && StructKeyExists( - application.$wheels, - "engineAdapter" - ) - ) { - return application.$wheels.engineAdapter; - } - Throw(type = "Wheels.EngineAdapterNotInitialized", message = "Engine adapter has not been initialized yet."); - } - - /** - * Returns true if the engine adapter is available in application scope. - * Used by functions that may be called before onApplicationStart completes. - */ - public boolean function $hasEngineAdapter() { - return ( - StructKeyExists(application, "wheels") && IsStruct(application.wheels) && StructKeyExists( - application.wheels, - "engineAdapter" - ) - ) - || ( - StructKeyExists(application, "$wheels") && IsStruct(application.$wheels) && StructKeyExists( - application.$wheels, - "engineAdapter" - ) - ); - } - - // ====================================================================== - // PARAMS FUNCTIONS - // ====================================================================== - - /** - * Internal function. - */ - public any function $cleanInlist(required string where) { - local.rv = arguments.where; - local.regex = "IN\s?\(.*?,?\s?.*?\)"; - local.in = ReFind(local.regex, local.rv, 1, true); - while (local.in.len[1]) { - local.str = Mid(local.rv, local.in.pos[1], local.in.len[1]); - local.rv = RemoveChars(local.rv, local.in.pos[1], local.in.len[1]); - local.cleaned = $listClean(local.str); - local.rv = Insert(local.cleaned, local.rv, local.in.pos[1] - 1); - local.in = ReFind(local.regex, local.rv, local.in.pos[1] + Len(local.cleaned), true); - } - return local.rv; - } - - /** - * Removes whitespace between list elements. - * Optional argument to return the list as an array. - */ - public any function $listClean(required string list, string delim = ",", string returnAs = "string") { - local.rv = ListToArray(arguments.list, arguments.delim); - local.iEnd = ArrayLen(local.rv); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.rv[local.i] = Trim(local.rv[local.i]); - } - if (arguments.returnAs != "array") { - local.rv = ArrayToList(local.rv, arguments.delim); - } - return local.rv; - } - - /** - * Converts a comma delimted list to a struct - */ - public struct function $listToStruct(required string list, string value = 1) { - local.rv = {}; - local.cleanList = $listClean(list = arguments.list, returnAs = "array"); - for (local.key in local.cleanList) { - local.rv[local.key] = arguments.value; - } - return local.rv; - } - - /** - * Internal function. Wheels's canonical plural-or-singular argument alias - * helper. If `args.` is set, copy it to `args.` and delete - * the original — so the function body can read `args.` uniformly - * regardless of which name the caller used. With `required=true`, throws - * `Wheels.IncorrectArguments` when neither name is provided. - * - * Canonical examples: - * - `combine = "columnNames,columnName"` — migrator column helpers in - * vendor/wheels/migrator/TableDefinition.cfc - * - `combine = "properties,property"` — model validations in - * vendor/wheels/model/validations.cfc - * - `combine = "formats,format"` — controller provides() in - * vendor/wheels/controller/provides.cfc - * - `combine = "referenceNames,columnNames"` — t.references() per #2781 - * - * When adding a new helper that takes a list-or-single argument, follow - * this pattern: declare the plural form on the signature (NOT required), - * then call $combineArguments(required=true) at the top of the body so the - * alias works AND the required-ness is enforced at runtime. - */ - public void function $combineArguments( - required struct args, - required string combine, - required boolean required = false, - string extendedInfo = "" - ) { - local.first = ListGetAt(arguments.combine, 1); - local.second = ListGetAt(arguments.combine, 2); - if (StructKeyExists(arguments.args, local.second)) { - arguments.args[local.first] = arguments.args[local.second]; - StructDelete(arguments.args, local.second); - } - if (arguments.required && application.wheels.showErrorInformation) { - if (!StructKeyExists(arguments.args, local.first) || !Len(arguments.args[local.first])) { - Throw( - type = "Wheels.IncorrectArguments", - message = "The `#local.second#` or `#local.first#` argument is required but was not passed in.", - extendedInfo = "#arguments.extendedInfo#" - ); - } - } - } - - - /** - * Check to see if all keys in the list exist for the structure and have length. - */ - public boolean function $structKeysExist(required struct struct, string keys = "") { - local.rv = true; - local.keyArray = ListToArray(arguments.keys); - local.iEnd = ArrayLen(local.keyArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.key = local.keyArray[local.i]; - if ( - !StructKeyExists(arguments.struct, local.key) - || ( - IsSimpleValue(arguments.struct[local.key]) - && !Len(arguments.struct[local.key]) - ) - ) { - local.rv = false; - break; - } - } - return local.rv; - } - - /** - * Creates a struct of the named arguments passed in to a function (i.e. the ones not explicitly defined in the arguments list). - * - * @defined List of already defined arguments that should not be added. - */ - public struct function $namedArguments(required string $defined) { - local.rv = {}; - for (local.key in arguments) { - if (!ListFindNoCase(arguments.$defined, local.key) && Left(local.key, 1) != "$") { - local.rv[local.key] = arguments[local.key]; - } - } - return local.rv; - } - - /** - * Internal function. - */ - public struct function $dollarify(required struct input, required string on) { - for (local.key in arguments.input) { - if (ListFindNoCase(arguments.on, local.key)) { - arguments.input["$" & local.key] = arguments.input[local.key]; - StructDelete(arguments.input, local.key); - } - } - return arguments.input; - } - - /** - * Internal function. - */ - public void function $args( - required struct args, - required string name, - string reserved = "", - string combine = "", - string required = "" - ) { - if (Len(arguments.combine)) { - local.combineKeysArray = ListToArray(arguments.combine); - local.iEnd = ArrayLen(local.combineKeysArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.item = local.combineKeysArray[local.i]; - local.first = ListGetAt(local.item, 1, "/"); - local.second = ListGetAt(local.item, 2, "/"); - local.required = false; - if (ListLen(local.item, "/") > 2 || ListFindNoCase(local.first, arguments.required)) { - local.required = true; - } - $combineArguments(args = arguments.args, combine = "#local.first#,#local.second#", required = local.required); - } - } - if (application.wheels.showErrorInformation) { - if (ListLen(arguments.reserved)) { - local.iEnd = ListLen(arguments.reserved); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.item = ListGetAt(arguments.reserved, local.i); - if (StructKeyExists(arguments.args, local.item)) { - Throw( - type = "Wheels.IncorrectArguments", - message = "The `#local.item#` argument cannot be passed in since it will be set automatically by Wheels." - ); - } - } - } - } - if (StructKeyExists(application.wheels.functions, arguments.name)) { - $engineAdapter().structAppendDefaults(arguments.args, application.wheels.functions[arguments.name]); - } - - // make sure that the arguments marked as required exist - if (Len(arguments.required)) { - local.requiredKeysArray = ListToArray(arguments.required); - local.iEnd = ArrayLen(local.requiredKeysArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.arg = local.requiredKeysArray[local.i]; - if (!StructKeyExists(arguments.args, local.arg)) { - Throw( - type = "Wheels.IncorrectArguments", - message = "The `#local.arg#` argument is required but not passed in." - ); - } - } - } - } - - // ====================================================================== - // MISC FUNCTIONS - // ====================================================================== - - /** - * Call CFML's canonicalize() function but set to blank string if the result is null (happens on Lucee 5). - */ - public string function $canonicalize(required string input) { - try { - local.rv = Canonicalize(arguments.input, false, false); - if (IsNull(local.rv)) { - local.rv = ""; - } - } catch (any e) { - // Lucee's Canonicalize() delegates to Java's URLDecoder, which throws - // IllegalArgumentException for inputs containing malformed percent-encoded - // sequences (e.g. %% or a lone % not followed by two hex digits). - // Fall back to the raw input; it will still be HTML-encoded by the caller. - local.rv = arguments.input; - } - return local.rv; - } - - /** - * Internal function. - * Disambiguates a D1/D2/YYYY slash date: a component greater than 12 cannot - * be a month so the format is unambiguous; otherwise the engine adapter's - * locale preference decides (MM/DD/YYYY on Lucee / Adobe, DD/MM/YYYY on - * BoxLang). All slash-date parsing should funnel through this helper. - */ - public date function $parseSlashDate(required numeric d1, required numeric d2, required numeric year) { - if (arguments.d1 > 12) { - // the first component cannot be a month so it must be the day (DD/MM/YYYY) - return CreateDate(arguments.year, arguments.d2, arguments.d1); - } else if (arguments.d2 > 12) { - // the second component cannot be a month so it must be the day (MM/DD/YYYY) - return CreateDate(arguments.year, arguments.d1, arguments.d2); - } else { - return $engineAdapter().parseAmbiguousSlashDate(arguments.d1, arguments.d2, arguments.year); - } - } - - /** - * Internal function. - */ - public string function $convertToString(required any value, string type = "") { - // Normalize inputs - local.val = arguments.value; - local.detectedType = arguments.type; - - // Coerce Oracle JDBC objects (TIMESTAMP, DATE) to CFML datetime values. - if (IsObject(local.val)) { - local.coerced = $engineAdapter().coerceOracleObject(local.val); - if (!IsObject(local.coerced) || local.coerced.hashCode() != local.val.hashCode()) { - local.val = local.coerced; - if (IsDate(local.val)) { - local.detectedType = "datetime"; - } else { - local.detectedType = "string"; - } - } - } - - // If no explicit type passed, try to detect a sensible one - if (!Len(detectedType)) { - if (IsArray(val)) { - detectedType = "array"; - } else if (IsStruct(val)) { - detectedType = "struct"; - } else if (IsBinary(val)) { - detectedType = "binary"; - } else if (IsNumeric(val)) { - detectedType = "integer"; - } else if (IsDate(val)) { - detectedType = "datetime"; - } else { - detectedType = "string"; - } - } - - // --- EARLY DATE/TIME PROMOTION --- - // If the caller provided a non-datetime type (eg "string") but the value looks like a date/time, - // promote it to datetime so the switch branch will canonicalize properly. - if ( - detectedType NEQ "datetime" - AND IsSimpleValue(val) - AND Len(Trim(val)) - ) { - local.s = Trim(val); - - // Match patterns loosely so they work for plain dates too - local.patternAMPM = '^\d{1,2}/\d{1,2}/\d{4}(\s+\d{1,2}:\d{2}(\s*(AM|PM))?)?$'; - local.patternISO = '^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?$'; - local.patternSlash = '^\s*\d{1,2}/\d{1,2}/\d{4}\s*$'; - - - // Day name or other verbose formats are ignored to avoid false positives - if ( - ReFindNoCase(local.patternAMPM, local.s) OR ReFindNoCase(local.patternISO, local.s) OR ReFindNoCase( - local.patternSlash, - local.s - ) - ) { - // Promote to datetime so the datetime branch will run below - detectedType = "datetime"; - } - } - - // Pre-process date strings with AM/PM that may be parsed differently per engine - if ( - $engineAdapter().isBoxLang() && IsSimpleValue(arguments.value) && ReFindNoCase( - "^\d{1,2}/\d{1,2}/\d{4} \d{1,2}:\d{2} (AM|PM)$", - arguments.value - ) - ) { - // Manually parse the slash date to avoid engine-specific interpretation, - // disambiguating day/month through $parseSlashDate() - local.parts = ListToArray(arguments.value, " "); - local.datePart = local.parts[1]; - local.timePart = local.parts[2]; - local.amPm = local.parts[3]; - - local.dateComponents = ListToArray(local.datePart, "/"); - local.timeComponents = ListToArray(local.timePart, ":"); - - local.parsedDate = $parseSlashDate( - d1 = Val(local.dateComponents[1]), - d2 = Val(local.dateComponents[2]), - year = Val(local.dateComponents[3]) - ); - local.hour = Val(local.timeComponents[1]); - local.minute = Val(local.timeComponents[2]); - - if (local.amPm == "PM" && local.hour != 12) { - local.hour += 12; - } else if (local.amPm == "AM" && local.hour == 12) { - local.hour = 0; - } - val = CreateDateTime( - Year(local.parsedDate), - Month(local.parsedDate), - Day(local.parsedDate), - local.hour, - local.minute, - 0 - ); - detectedType = "datetime"; - } - - // --- SWITCH ON (possibly promoted) TYPE --- - switch (detectedType) { - case "array": - return ArrayToList(val); - case "struct": - local.kList = ListSort(StructKeyList(val), "textnocase", "asc"); - local.out = ""; - for (local.k in ListToArray(local.kList)) { - local.out = ListAppend(local.out, local.k & "=" & val[local.k]); - } - return local.out; - case "binary": - return ToString(val); - case "float": - case "integer": - if (!Len(val)) { - return ""; - } - if (val == "true") { - return "1"; - } - return Val(val); - case "boolean": - if (Len(val)) { - return (val IS true) ? "true" : "false"; - } - return ""; - case "datetime": - // If it's already a date object, canonicalize - if (IsDate(val)) { - return DateFormat(val, "yyyy-mm-dd") & " " & TimeFormat(val, "HH:mm:ss"); - } - - // If it is a string that looks like a date, try parsing - if (IsSimpleValue(val)) { - local.s2 = Trim(val); - // Try ParseDateTime (which handles many formats) - try { - local.dt = ParseDateTime(local.s2); - if (IsDate(local.dt)) { - return DateFormat(local.dt, "yyyy-mm-dd") & " " & TimeFormat(local.dt, "HH:mm:ss"); - } - } catch (any e) { - // fallback parsing attempts for common formats - - // 1) ISO YYYY-MM-DD[ hh[:mm[:ss]]] - // Single-backslash escapes: in CFML "\\d" is a literal - // backslash + d in the compiled regex, which never matches a - // digit — the branch was dead. Mirrors the already-fixed - // slash-format branch below (#2933 carry-forward, #2977). - if (ReFind("(?i)^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$", local.s2)) { - local.parts = ReReplace(local.s2, "^(\d{4})-(\d{2})-(\d{2}).*$", "\1-\2-\3", "all"); - local.timePart = ReReplace(local.s2, ".*[ T](\d{1,2}:\d{2}(?::\d{2})?).*$", "\1", "all"); - if (Len(local.timePart) AND local.timePart NEQ local.s2) { - // has time - local.dt = ParseDateTime(local.parts & " " & local.timePart); - if (IsDate(local.dt)) { - return DateFormat(local.dt, "yyyy-mm-dd") & " " & TimeFormat(local.dt, "HH:mm:ss"); - } - } else { - // date only - local.dt = CreateDate( - Val(ListGetAt(local.parts, 1, "-")), - Val(ListGetAt(local.parts, 2, "-")), - Val(ListGetAt(local.parts, 3, "-")) - ); - return DateFormat(local.dt, "yyyy-mm-dd") & " 00:00:00"; - } - } - - // 2) Slash format DD/MM/YYYY or MM/DD/YYYY — disambiguated by $parseSlashDate() - if (ReFind("^\d{1,2}/\d{1,2}/\d{4}", local.s2)) { - local.comps = ListToArray(local.s2, "/"); - local.dt = $parseSlashDate( - d1 = Val(local.comps[1]), - d2 = Val(local.comps[2]), - year = Val(local.comps[3]) - ); - // if time exists in same string, try to parse it using ParseDateTime - if (ReFind("\d{1,2}:\d{2}", local.s2)) { - try { - local.dt2 = ParseDateTime(local.s2); - if (IsDate(local.dt2)) { - return DateFormat(local.dt2, "yyyy-mm-dd") & " " & TimeFormat(local.dt2, "HH:mm:ss"); - } - } catch (any e2) { - // fallback to midnight - return DateFormat(local.dt, "yyyy-mm-dd") & " 00:00:00"; - } - } - return DateFormat(local.dt, "yyyy-mm-dd") & " 00:00:00"; - } - } - } - // If we reach here, parsing failed — return original string to allow comparison - return val; - default: - // Default: return raw value as string (no conversion) - return val; - } - } - - /** - * Internal function. - */ - public xml function $toXml(required any data) { - // only instantiate the toXml object once per request - if (!StructKeyExists(request.wheels, "toXml")) { - request.wheels.toXml = $createObjectFromRoot( - path = "#application.wheels.wheelsComponentPath#.vendor.toXml", - fileName = "toXML", - method = "init" - ); - } - - return request.wheels.toXml.toXml(arguments.data); - } - - /** - * Obfuscates a value. Typically used for hiding primary key values when passed along in the URL. - * - * [section: Global Helpers] - * [category: Miscellaneous Functions] - * - * @param The value to obfuscate. - */ - public string function obfuscateParam(required any param) { - local.rv = arguments.param; - local.param = ArrayToList(ReMatch("[0-9]+", arguments.param), ""); - if (Len(local.param) && local.param > 0 && Left(local.param, 1) != 0) { - local.iEnd = Len(local.param); - local.a = (10^local.iEnd) + Reverse(local.param); - local.b = 0; - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.b += Left(Right(local.param, local.i), 1); - } - if (IsValid("integer", local.a)) { - local.rv = FormatBaseN(local.b + 154, 16) & FormatBaseN(BitXor(local.a, 461), 16); - } - } - return local.rv; - } - - /** - * Deobfuscates a value. - * - * [section: Global Helpers] - * [category: Miscellaneous Functions] - * - * @param The value to deobfuscate. - */ - public string function deobfuscateParam(required string param) { - if (Val(arguments.param) != arguments.param) { - try { - local.checksum = Left(arguments.param, 2); - local.rv = Right(arguments.param, Len(arguments.param) - 2); - local.z = BitXor(InputBaseN(local.rv, 16), 461); - local.rv = ""; - local.iEnd = Len(local.z) - 1; - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.rv &= Left(Right(local.z, local.i), 1); - } - local.checkSumTest = 0; - local.iEnd = Len(local.rv); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.checkSumTest += Left(Right(local.rv, local.i), 1); - } - local.c1 = ToString(FormatBaseN(local.checkSumTest + 154, 10)); - local.c2 = InputBaseN(local.checksum, 16); - if (local.c1 != local.c2) { - local.rv = arguments.param; - } - } catch (any e) { - local.rv = arguments.param; - } - } else { - local.rv = arguments.param; - } - return local.rv; - } - - /** - * Returns a list of the names of all installed plugins. - * - * [section: Global Helpers] - * [category: Miscellaneous Functions] - */ - public string function pluginNames() { - return StructKeyList(application.wheels.plugins); - } - - /** - * Returns an associated MIME type based on a file extension. - * - * [section: Global Helpers] - * [category: Miscellaneous Functions] - * - * @extension The extension to get the MIME type for. - * @fallback The fallback MIME type to return. - */ - public string function mimeTypes(required string extension, string fallback = "application/octet-stream") { - local.rv = arguments.fallback; - if (StructKeyExists(application.wheels.mimetypes, arguments.extension)) { - local.rv = application.wheels.mimetypes[arguments.extension]; - } - return local.rv; - } - - /** - * Adds a new MIME type to your Wheels application for use with responding to multiple formats. - * - * [section: Configuration] - * [category: Miscellaneous Functions] - * - * @extension File extension to add. - * @mimeType Matching MIME type to associate with the file extension. - */ - public void function addFormat(required string extension, required string mimeType) { - local.appKey = $appKey(); - application[local.appKey].formats[arguments.extension] = arguments.mimeType; - } - - /** - * Internal function. - */ - public string function $appKey() { - local.rv = "wheels"; - if (StructKeyExists(application, "$wheels")) { - local.rv = "$wheels"; - } - return local.rv; - } - - /** - * Internal function. Returns the application-cached Plugins instance so the - * request-lifecycle call sites (onDIcomplete on controllers, models and the - * dispatcher, plus $runOnRequestStart) don't construct a throwaway - * wheels.Plugins — and its wheels.Global parent pseudo-constructor — per - * request / per materialized model row (issue 2897, Stage 3). Falls back to - * a fresh instance during bootstrap windows where the cache has not been - * populated yet, or where the application scope is undefined (CLI / test - * bootstrap). Sharing one instance is safe because $initializeMixins keeps - * its scratch state local-scoped. - */ - public any function $pluginObj() { - if (IsDefined("application")) { - local.appKey = StructKeyExists(application, "$wheels") ? "$wheels" : "wheels"; - if (StructKeyExists(application, local.appKey) && StructKeyExists(application[local.appKey], "PluginObj")) { - return application[local.appKey].PluginObj; - } - } - return CreateObject("component", "wheels.Plugins"); - } - - /** - * Internal function. Records a deprecation warning through a single shared - * policy: the first call for a given feature logs a warning to the standard - * wheels log and registers the warning in - * application[appKey].deprecationWarnings so running apps can surface it - * (debug panel, tooling). Subsequent calls for the same feature are no-ops, - * making the helper safe to call from per-request code paths. The dedup - * check, registration, and log write run atomically under an exclusive - * lock so concurrent first callers (e.g. parallel first requests hitting a - * deprecated per-request helper) register and log exactly once. If the - * Wheels application struct does not exist yet, the helper is a silent - * no-op: with no registry to dedup against, logging would fire on every - * call, and all framework callers run after the struct is established. - * - * @feature Stable identifier for the deprecated feature (e.g. "plugins-directory", "paginationLinks"). - * @message Human-readable message: what is deprecated, what replaces it, and when it goes away. - * @docUrl Optional URL of the migration guide, appended to the logged message. - */ - public void function $deprecated(required string feature, required string message, string docUrl = "") { - try { - local.appKey = $appKey(); - if (StructKeyExists(application, local.appKey)) { - // One app-wide lock (rather than per-feature) also serializes the lazy - // creation of the registry array itself; contention is a non-issue at - // once-per-feature-per-application frequency. - lock name="wheels_deprecated_registry" type="exclusive" timeout="5" { - if (!StructKeyExists(application[local.appKey], "deprecationWarnings")) { - application[local.appKey].deprecationWarnings = []; - } - for (local.existing in application[local.appKey].deprecationWarnings) { - if (local.existing.feature == arguments.feature) { - return; - } - } - ArrayAppend(application[local.appKey].deprecationWarnings, { - feature = arguments.feature, - message = arguments.message, - url = arguments.docUrl - }); - // Log if-and-only-if the registration above just succeeded; the - // registry is what enforces the warn-once policy for the log too. - try { - local.text = "[Wheels] Deprecation: " & arguments.message; - if (Len(arguments.docUrl)) { - local.text &= " See: " & arguments.docUrl; - } - WriteLog(type = "warning", text = local.text, file = "wheels"); - } catch (any e) { - // Logging is best-effort; the registry entry above already records the warning. - } - } - } - } catch (any e) { - // Best-effort by design (including lock timeouts); never let a - // deprecation notice break the caller. - } - } - - // Returns the running framework version. Delegates to BuildInfo.cfc, which - // is the authoritative version source. The historical box.json-reading - // implementation (with monorepo / wheels-base-template fallback chain) - // was retired when BuildInfo became the source of truth — see the BuildInfo - // header for migration context. Kept as a thin wrapper because callers - // upstream of onapplicationstart (e.g. PackageLoader, Plugins) and tests - // reference $readFrameworkVersion by name. - public string function $readFrameworkVersion() { - return new wheels.BuildInfo().version(); - } - - public string function $checkMinimumVersion(required string engine, required string version) { - local.rv = ""; - local.version = Replace(arguments.version, ".", ",", "all"); - local.major = Val(ListGetAt(local.version, 1)); - local.minor = 0; - local.patch = 0; - local.build = 0; - if (ListLen(local.version) > 1) { - local.minor = Val(ListGetAt(local.version, 2)); - } - if (ListLen(local.version) > 2) { - local.patch = Val(ListGetAt(local.version, 3)); - } - if (ListLen(local.version) > 3) { - local.build = Val(ListGetAt(local.version, 4)); - } - if (arguments.engine == "BoxLang") { - local.minimumMajor = "1"; - local.minimumMinor = "0"; - local.minimumPatch = "0"; - local.maximumMajor = "1"; - local.maximumMinor = "15"; - local.maximumPatch = "999"; - - // Check minimum version - if ( - local.major < local.minimumMajor - || (local.major == local.minimumMajor && local.minor < local.minimumMinor) - || (local.major == local.minimumMajor && local.minor == local.minimumMinor && local.patch < local.minimumPatch) - ) { - local.rv = "The Wheels framework requires BoxLang version #local.minimumMajor#.#local.minimumMinor#.#local.minimumPatch# or higher. You are currently running version #arguments.version#."; - } - - // Check maximum version (optional - for major version compatibility) - if ( - local.major > local.maximumMajor - || (local.major == local.maximumMajor && local.minor > local.maximumMinor) - || (local.major == local.maximumMajor && local.minor == local.maximumMinor && local.patch > local.maximumPatch) - ) { - local.rv = "The Wheels framework has been tested up to BoxLang version #local.maximumMajor#.#local.maximumMinor#.#local.maximumPatch#. You are currently running version #arguments.version#. Please check for framework updates or compatibility issues."; - } - } else if (arguments.engine == "Lucee") { - local.minimumMajor = "5"; - local.minimumMinor = "3"; - local.minimumPatch = "2"; - local.minimumBuild = "77"; - // per-major-release floor consumed by the `StructKeyExists(local, local.major)` - // check below (keyed by the running engine's major version number) - local.5 = {minimumMinor = 2, minimumPatch = 1, minimumBuild = 9}; - } else if (arguments.engine == "Adobe ColdFusion") { - // Adobe ColdFusion 2018 is the oldest supported Adobe engine - // (CF 11 / 2016 are end-of-life and no longer supported) - local.minimumMajor = "2018"; - local.minimumMinor = "0"; - local.minimumPatch = "0"; - local.minimumBuild = ""; - } else if (arguments.engine == "RustCFML") { - // RustCFML is a pre-1.0, rapidly evolving experimental engine that - // Wheels supports on a best-effort basis. Accept any version (leave - // local.rv = "") rather than enforcing a minimum; per-version - // divergences are tracked via the RustCFMLAdapter capabilities. - local.rv = ""; - } else { - local.rv = false; - } - if (StructKeyExists(local, "minimumMajor")) { - if ( - local.major < local.minimumMajor - || (local.major == local.minimumMajor && local.minor < local.minimumMinor) - || (local.major == local.minimumMajor && local.minor == local.minimumMinor && local.patch < local.minimumPatch) - || ( - local.major == local.minimumMajor - && local.minor == local.minimumMinor - && local.patch == local.minimumPatch - && Len(local.minimumBuild) - && local.build < local.minimumBuild - ) - ) { - local.rv = local.minimumMajor & "." & local.minimumMinor & "." & local.minimumPatch; - if (Len(local.minimumBuild)) { - local.rv &= "." & local.minimumBuild; - } - } - if (StructKeyExists(local, local.major)) { - // special requirements for having a specific minor or patch version within a major release exists - if ( - local.minor < local[local.major].minimumMinor - || (local.minor == local[local.major].minimumMinor && local.patch < local[local.major].minimumPatch) - ) { - local.rv = local.major & "." & local[local.major].minimumMinor & "." & local[local.major].minimumPatch; - } - } - } - return local.rv; - } - - /** - * Internal function. Normalizes mixin-collision records to a single - * shared shape: {target, method, firstProvider, secondProvider, - * acknowledged, source}. Plugins.cfc emits legacy-shaped records - * ({existingPlugin, overridingPlugin}) while PackageLoader.cfc and the - * cross-system merge in $loadPackages emit the shared shape directly; - * all of them end up in the same application.wheels.mixinCollisions - * array, which /wheels/plugins and the development debug footer consume - * unconditionally — a mixed-shape array crashes those surfaces with a - * "key doesn't exist" error. - */ - public array function $normalizeMixinCollisions(required array collisions) { - local.rv = []; - for (local.c in arguments.collisions) { - ArrayAppend(local.rv, { - target = local.c.target, - method = local.c.method, - firstProvider = StructKeyExists(local.c, "firstProvider") ? local.c.firstProvider : local.c.existingPlugin, - secondProvider = StructKeyExists(local.c, "secondProvider") ? local.c.secondProvider : local.c.overridingPlugin, - acknowledged = StructKeyExists(local.c, "acknowledged") ? local.c.acknowledged : false, - source = StructKeyExists(local.c, "source") ? local.c.source : "plugin" - }); - } - return local.rv; - } - - /** - * Internal function. - */ - public void function $loadPlugins() { - local.appKey = $appKey(); - local.pluginPath = application[local.appKey].webPath & application[local.appKey].pluginPath; - application[local.appKey].PluginObj = $createObjectFromRoot( - path = "wheels", - fileName = "Plugins", - method = "$init", - pluginPath = local.pluginPath, - deletePluginDirectories = application[local.appKey].deletePluginDirectories, - overwritePlugins = application[local.appKey].overwritePlugins, - loadIncompatiblePlugins = application[local.appKey].loadIncompatiblePlugins, - wheelsEnvironment = application[local.appKey].environment, - wheelsVersion = application[local.appKey].version - ); - application[local.appKey].plugins = application[local.appKey].PluginObj.getPlugins(); - application[local.appKey].pluginMeta = application[local.appKey].PluginObj.getPluginMeta(); - application[local.appKey].incompatiblePlugins = application[local.appKey].PluginObj.getIncompatiblePlugins(); - application[local.appKey].dependantPlugins = application[local.appKey].PluginObj.getDependantPlugins(); - application[local.appKey].versionMismatchPlugins = application[local.appKey].PluginObj.getVersionMismatchPlugins(); - // Plugins.cfc emits legacy-shaped collision records ({existingPlugin, - // overridingPlugin}); normalize them to the shared shape at the merge - // point so package- and cross-system records (which already use - // {firstProvider, secondProvider}) can live in the same array without - // crashing the consumers (/wheels/plugins and the debug footer). - application[local.appKey].mixinCollisions = $normalizeMixinCollisions( - application[local.appKey].PluginObj.getMixinCollisions() - ); - application[local.appKey].mixins = application[local.appKey].PluginObj.getMixins(); - application[local.appKey].pluginMiddleware = application[local.appKey].PluginObj.getPluginMiddleware(); - // Invoke register(container) on ServiceProviderInterface plugins before activation - if (IsDefined("application.wheelsdi") && ArrayLen(application[local.appKey].PluginObj.getServiceProviders())) { - application[local.appKey].PluginObj.$invokeServiceProviderRegister(application.wheelsdi); - // Boot after all register() calls complete — plugins can now resolve services - application[local.appKey].PluginObj.$invokeServiceProviderBoot(application[local.appKey]); - } - // Invoke onPluginActivate lifecycle hook on all plugins now that everything is in the application scope - application[local.appKey].PluginObj.$invokeOnPluginActivate(); - } - - /** - * Discovers and loads packages from the vendor/ directory via PackageLoader. - * Merges package mixins into the existing application mixins struct so they - * participate in the standard $initializeMixins injection pipeline. - */ - public void function $loadPackages() { - local.appKey = $appKey(); - local.vendorPath = ExpandPath(application[local.appKey].packagePath); - - application[local.appKey].PackageLoaderObj = $createObjectFromRoot( - path = "wheels", - fileName = "PackageLoader", - method = "init", - vendorPath = local.vendorPath, - wheelsVersion = application[local.appKey].version, - wheelsEnvironment = application[local.appKey].environment - ); - - application[local.appKey].packages = application[local.appKey].PackageLoaderObj.getPackages(); - application[local.appKey].packageMeta = application[local.appKey].PackageLoaderObj.getPackageMeta(); - application[local.appKey].failedPackages = application[local.appKey].PackageLoaderObj.getFailedPackages(); - - // Ensure mixinCollisions exists (unset when no plugins loaded before packages) - if (!StructKeyExists(application[local.appKey], "mixinCollisions")) { - application[local.appKey].mixinCollisions = []; - } - - // Carry forward any collisions the PackageLoader detected internally - for (local.c in application[local.appKey].PackageLoaderObj.getMixinCollisions()) { - ArrayAppend(application[local.appKey].mixinCollisions, local.c); - } - - // Merge package mixins into the existing mixins struct (plugins loaded first, packages overlay). - // Detect cross-system collisions — a package method that shadows a plugin method on the - // same target — before StructAppend silently overwrites. - local.pkgMixins = application[local.appKey].PackageLoaderObj.getMixins(); - local.pluginProviders = StructKeyExists(application[local.appKey], "PluginObj") - ? application[local.appKey].PluginObj.getMethodProviders() - : {}; - local.pkgProviders = application[local.appKey].PackageLoaderObj.getMethodProviders(); - for (local.target in local.pkgMixins) { - if (!StructKeyExists(application[local.appKey].mixins, local.target)) { - application[local.appKey].mixins[local.target] = {}; - } - for (local.methodName in local.pkgMixins[local.target]) { - if (StructKeyExists(application[local.appKey].mixins[local.target], local.methodName)) { - // Only treat this as a cross-system collision when the existing entry - // came from a known plugin. Without an attributable plugin provider - // the prior entry could be framework-internal or pre-seeded, and a - // "migrate the plugin" recommendation would be misleading. - local.pluginAttributable = StructKeyExists(local.pluginProviders, local.target) - && StructKeyExists(local.pluginProviders[local.target], local.methodName); - if (!local.pluginAttributable) { - continue; - } - local.pluginName = local.pluginProviders[local.target][local.methodName]; - local.pkgName = StructKeyExists(local.pkgProviders, local.target) - && StructKeyExists(local.pkgProviders[local.target], local.methodName) - ? local.pkgProviders[local.target][local.methodName] - : "(unknown package)"; - ArrayAppend(application[local.appKey].mixinCollisions, { - target = local.target, - method = local.methodName, - firstProvider = local.pluginName, - secondProvider = local.pkgName, - acknowledged = false, - source = "cross" - }); - WriteLog( - type = "warning", - text = "[Wheels] Cross-system mixin collision: method '#local.methodName#' on target '#local.target#' provided by plugin '#local.pluginName#' is being overwritten by package '#local.pkgName#'. Migrate the plugin to a package or remove the duplicate to resolve." - ); - } - } - StructAppend(application[local.appKey].mixins[local.target], local.pkgMixins[local.target]); - } - - // Merge package middleware into pluginMiddleware (shared pipeline) - local.pkgMiddleware = application[local.appKey].PackageLoaderObj.getPackageMiddleware(); - for (local.mw in local.pkgMiddleware) { - ArrayAppend(application[local.appKey].pluginMiddleware, local.mw); - } - - // Invoke ServiceProvider register/boot if DI container exists. The - // gate asks the loader (not just getServiceProviders()) because lazy - // service-hinted packages aren't instantiated yet at this point — - // $invokeServiceProviderRegister pulls them into the lifecycle, so a - // vendor tree containing only lazy service packages still needs the - // lifecycle invoked. - if (IsDefined("application.wheelsdi") && application[local.appKey].PackageLoaderObj.$hasServiceProviderWork()) { - application[local.appKey].PackageLoaderObj.$invokeServiceProviderRegister(application.wheelsdi); - application[local.appKey].PackageLoaderObj.$invokeServiceProviderBoot(application[local.appKey]); - // Re-sync the application-scope copy so register()/boot() failure - // records are visible there too. Adobe CF copies arrays by value on - // assignment, so the copy taken above (pre-invoke) never receives - // lifecycle-phase entries on those engines — only Lucee/BoxLang share - // the reference. Re-assigning is harmless on Lucee/BoxLang (same - // reference) and required on Adobe (fresh copy including new entries). - application[local.appKey].failedPackages = application[local.appKey].PackageLoaderObj.getFailedPackages(); - } - - // Surface an aggregate summary when any packages failed to load. Without - // this, PackageLoader records each failure in variables.failedPackages and - // emits per-package WriteLog calls — but a developer who hits a downstream - // "No matching function [BASECOATINCLUDES]" error has no obvious place to - // look. Logging a single high-visibility WARN to wheels.log + a stronger - // one to wheels-errors.log gives a clear breadcrumb back to the root cause. - // Runs after the ServiceProvider lifecycle invoke so register()/boot() - // failures appear in the same summary as load-phase failures. - if (ArrayLen(application[local.appKey].failedPackages)) { - local.failNames = ""; - local.failDetail = ""; - for (local.fp in application[local.appKey].failedPackages) { - local.failNames = ListAppend(local.failNames, local.fp.name); - local.failDetail &= " - " & local.fp.name & ": " & local.fp.error & Chr(10); - } - try { - writeLog( - file = "wheels", - type = "warning", - text = "Wheels: " & ArrayLen(application[local.appKey].failedPackages) - & " package(s) failed to load: " & local.failNames - & ". Helpers / services these packages provide will be unavailable —" - & " calling code typically surfaces this as 'No matching function [...]" - & "' or 'No service registered with the name [...]'." - & " Per-package detail in wheels-errors.log." - ); - writeLog( - file = "wheels-errors", - type = "error", - text = "Wheels: " & ArrayLen(application[local.appKey].failedPackages) - & " package(s) failed to load:" & Chr(10) & local.failDetail - ); - } catch (any e) { - // Logging is best-effort during application start. - } - } - } - - /** - * NB: url rewriting files need to be removed from here. - */ - public string function $buildReleaseZip( - string version = application.wheels.version, - string directory = ExpandPath("/") - ) { - local.name = "wheels-" & LCase(Replace(arguments.version, " ", "-", "all")); - local.name = Replace(local.name, "alpha-", "alpha."); - local.name = Replace(local.name, "beta-", "beta."); - local.name = Replace(local.name, "rc-", "rc."); - local.path = arguments.directory & local.name & ".zip"; - - // directories & files to add to the zip - local.include = [ - "/config", - "/app/controllers", - "/app/events", - "/app/lib", - "/app/migrator", - "files", - "/app/global", - "images", - "javascripts", - "miscellaneous", - "/app/models", - "/plugins", - "stylesheets", - "/tests", - "/app/views", - "/vendor/wheels", - "Application.cfc", - "../wheels.json", - "../box.json", - "index.cfm" - ]; - - // directories & files to be removed - local.exclude = ["/wheels/rocketunit_tests", "/wheels/public/build.cfm", "/wheels/tests"]; - - // filter out these bad boys - local.filter = "*.settings, *.classpath, *.project, *.DS_Store"; - - // The change log and license are copied to the wheels directory only for the build. - // FileCopy(ExpandPath("CHANGELOG.md"), ExpandPath("/wheels/CHANGELOG.md")); - // FileCopy(ExpandPath("LICENSE"), ExpandPath("/wheels/LICENSE")); - - // Entries starting with "/" or ".." → treat as project-root paths (keep original folder structure) - // Entries without "/" → treat as webroot (/public) paths - for (local.i in local.include) { - if (FileExists(ExpandPath(local.i))) { - if (Left(local.i, 1) neq "/" && Left(local.i, 2) neq "..") { - $zip(file = local.path, source = ExpandPath(local.i), prefix = "/public"); - } else { - $zip(file = local.path, source = ExpandPath(local.i)); - } - } else if (DirectoryExists(ExpandPath(local.i))) { - if (Left(local.i, 1) neq "/" && Left(local.i, 2) neq "..") { - $zip(file = local.path, source = ExpandPath(local.i), prefix = "/public/#local.i#"); - } else { - $zip(file = local.path, source = ExpandPath(local.i), prefix = local.i); - } - } else { - Throw( - type = "Wheels.Build", - message = "#ExpandPath(local.i)# not found", - detail = "All paths specified in local.include must exist" - ); - } - }; - - for (local.i in local.exclude) { - $zip(file = local.path, action = "delete", entrypath = local.i); - }; - $zip(file = local.path, action = "delete", filter = local.filter, recurse = true); - - // Clean up. - /* Might not need this because the wheels folder is outside the app now */ - // FileDelete(ExpandPath("/wheels/CHANGELOG.md")); - // FileDelete(ExpandPath("/wheels/LICENSE")); - - return local.path; - } - - /** - * Generates a 36-character UUID compatible with SQL Server's uniqueidentifier. - * - * [section: Global Helpers] - * [category: UUID Functions] - * - * @return A valid 36-character UUID string (e.g., 123e4567-e89b-12d3-a456-426614174000) - */ - public string function generateUUID() { - // Use Java UUID generator for a 36-character format - return CreateObject("java", "java.util.UUID").randomUUID().toString(); - } - - /** - * Returns a struct with information about the specified paginated query. - * The keys that will be included in the struct are `currentPage`, `totalPages` and `totalRecords`. - * - * [section: Controller] - * [category: Pagination Functions] - * - * @handle The handle given to the query to return pagination information for. - */ - public struct function pagination(string handle = "query") { - if ($get("showErrorInformation")) { - if (!StructKeyExists(request.wheels, arguments.handle)) { - Throw( - type = "Wheels.QueryHandleNotFound", - message = "Wheels couldn't find a query with the handle of `#arguments.handle#`.", - extendedInfo = "Make sure your `findAll` call has the `page` argument specified and matching `handle` argument if specified." - ); - } - } - return request.wheels[arguments.handle]; - } - - /** - * Allows you to set a pagination handle for a custom query so you can perform pagination on it in your view with `paginationLinks`. - * - * [section: Controller] - * [category: Pagination Functions] - * - * @totalRecords Total count of records that should be represented by the paginated links. - * @currentPage Page number that should be represented by the data being fetched and the paginated links. - * @perPage Number of records that should be represented on each page of data. - * @handle Name of handle to reference in `paginationLinks`. - */ - public void function setPagination( - required numeric totalRecords, - numeric currentPage = 1, - numeric perPage = 25, - string handle = "query" - ) { - // NOTE: this should be documented as a controller function but needs to be placed here because the findAll() method calls it. - - // All numeric values must be integers. - arguments.totalRecords = Fix(arguments.totalRecords); - arguments.currentPage = Fix(arguments.currentPage); - arguments.perPage = Fix(arguments.perPage); - - // The totalRecords argument cannot be negative. - if (arguments.totalRecords < 0) { - arguments.totalRecords = 0; - } - - // Default perPage to 25 if it's less then zero. - if (arguments.perPage <= 0) { - arguments.perPage = 25; - } - - // Calculate the total pages the query will have. - arguments.totalPages = Ceiling(arguments.totalRecords / arguments.perPage); - - // The currentPage argument shouldn't be less then 1 or greater then the number of pages. - if (arguments.currentPage >= arguments.totalPages) { - arguments.currentPage = arguments.totalPages; - } - if (arguments.currentPage < 1) { - arguments.currentPage = 1; - } - - // As a convenience for cfquery and cfloop when doing oldschool type pagination. - // Set startrow for cfquery and cfloop. - arguments.startRow = (arguments.currentPage * arguments.perPage) - arguments.perPage + 1; - - // Set maxrows for cfquery. - arguments.maxRows = arguments.perPage; - - // Set endrow for cfloop. - arguments.endRow = (arguments.startRow - 1) + arguments.perPage; - - // The endRow argument shouldn't be greater then the totalRecords or less than startRow. - if (arguments.endRow >= arguments.totalRecords) { - arguments.endRow = arguments.totalRecords; - } - if (arguments.endRow < arguments.startRow) { - arguments.endRow = arguments.startRow; - } - - local.args = Duplicate(arguments); - StructDelete(local.args, "handle"); - request.wheels[arguments.handle] = local.args; - } - - /** - * Creates a controller and calls an action on it. - * Which controller and action that's called is determined by the params passed in. - * Returns the result of the request either as a string or in a struct with `body`, `emails`, `files`, `flash`, `redirect`, `status`, and `type`. - * Primarily used for testing purposes. - * - * [section: Controller] - * [category: Miscellaneous Functions] - * - * @params The params struct to use in the request (make sure that at least `controller` and `action` are set). - * @method The HTTP method to use in the request (`get`, `post` etc). - * @returnAs Pass in `struct` to return all information about the request instead of just the final output (`body`). - * @rollback Pass in `true` to roll back all database transactions made during the request. - * @includeFilters Set to `before` to only execute "before" filters, `after` to only execute "after" filters or `false` to skip all filters. - */ - public any function processRequest( - required struct params, - string method, - string returnAs, - string rollback, - string includeFilters = true - ) { - $args(name = "processRequest", args = arguments); - - // Set the global transaction mode to rollback when specified. - // Also save the current state so we can set it back after the tests have run. - if (arguments.rollback) { - local.transactionMode = $get("transactionMode"); - $set(transactionMode = "rollback"); - } - - // Before proceeding we set the request method to our internal CGI scope if passed in. - // This way it's possible to mock a POST request so that an isPost() call in the action works as expected for example. - if (arguments.method != "get") { - request.cgi.request_method = arguments.method; - } - - // Look up controller & action via route name and method - if (StructKeyExists(arguments.params, "route")) { - local.route = $findRoute(argumentCollection = arguments.params, method = arguments.method); - arguments.params.controller = local.route.controller; - arguments.params.action = local.route.action; - } - - // Never deliver email or send files during test. - local.deliverEmail = $get(functionName = "sendEmail", name = "deliver"); - $set(functionName = "sendEmail", deliver = false); - local.deliverFile = $get(functionName = "sendFile", name = "deliver"); - $set(functionName = "sendFile", deliver = false); - - local.controller = controller(name = arguments.params.controller, params = arguments.params); - - // Set to ignore CSRF errors during testing. - local.controller.protectsFromForgery(with = "ignore"); - - local.controller.processAction(includeFilters = arguments.includeFilters); - local.response = local.controller.response(); - - // Get redirect info. - // If a delayed redirect was made we use the status code for that and set the body to a blank string. - // If not we use the current status code and response and set the redirect info to a blank string. - local.redirectDetails = local.controller.getRedirect(); - if (StructCount(local.redirectDetails)) { - local.body = ""; - local.redirect = local.redirectDetails.url; - local.status = local.redirectDetails.statusCode; - } else { - local.status = $statusCode(); - local.body = local.response; - local.redirect = ""; - } - - if (arguments.returnAs == "struct") { - local.rv = { - body = local.body, - emails = local.controller.getEmails(), - files = local.controller.getFiles(), - flash = local.controller.flash(), - redirect = local.redirect, - status = local.status, - type = $contentType() - }; - } else { - local.rv = local.body; - } - - // Clear the Flash so we can run several processAction calls without the Flash sticking around. - local.controller.$flashClear(); - - // Set back the global transaction mode to the previous value if it has been changed. - if (arguments.rollback) { - $set(transactionMode = local.transactionMode); - } - - // Set back the request method to GET (this is fine since the test suite is always run using GET). - request.cgi.request_method = "get"; - - // Set back email delivery setting to previous value. - $set(functionName = "sendEmail", deliver = local.deliverEmail); - $set(functionName = "sendFile", deliver = local.deliverFile); - - // Set back the status code to 200 so the test suite does not use the same code that the action that was tested did. - // If the test suite fails it will set the status code to 500 later. - $header(statusCode = 200); - - // Set the Content-Type header in case it was set to something else (e.g. application/json) during processing. - // It's fine to do this because we always want to return the test page as text/html. - $header(name = "Content-Type", value = "text/html", charset = "UTF-8"); - - return local.rv; - } - - public array function $splitOutsideFunctions(required string list, required string splitBy) { - local.rv = []; - local.temp = ""; - local.insideFunction = false; - local.bracketCount = 0; - - for (local.i = 1; i <= Len(arguments.list); i++) { - local.char = Mid(arguments.list, i, 1); - - // Check if we are entering or exiting a function's parentheses - if (local.char == "(") { - local.bracketCount++; - } else if (local.char == ")") { - local.bracketCount--; - } - - // Determine if we are inside a function (any content enclosed by parentheses) - if (local.bracketCount > 0) { - local.insideFunction = true; - } else if (local.bracketCount == 0) { - local.insideFunction = false; - } - - // Split based on commas outside functions - if (local.char == arguments.splitBy && !local.insideFunction) { - ArrayAppend(local.rv, Trim(local.temp)); - local.temp = ""; - } else { - local.temp &= local.char; - } - } - - // Append the final segment - if (Len(Trim(local.temp))) { - ArrayAppend(local.rv, Trim(local.temp)); - } - - return local.rv; - } - - /** - * Normalizes a nested key path by converting bracket notation (e.g., `form[user][email]`) to dot notation (e.g., `form.user.email`). - * - * [section: Global Helpers] - * [category: String Functions] - * - * @path The key path to normalize. - */ - public string function $normalizePath(required string path) { - local.norm = arguments.path; - local.norm = ReReplace(local.norm, "\[(.*?)\]", ".\1", "all"); - local.norm = ReReplace(local.norm, "^\.", "", "one"); - return local.norm; - } - - // ====================================================================== - // CORS FUNCTIONS - // ====================================================================== - - /** - * Wildcard domain match: check if the current cgi.server_name and port satisfies - * the passed in domain string whilst checking for wildcards - * - * @domain string to test against e.g *.foo.com - * @cgi Fake CGI Scope for Testing; will default to normal cgi scope - */ - public boolean function $wildcardDomainMatchCGI(required string domain, struct cgi) { - local.domain = arguments.domain; - local.cgi = StructKeyExists(arguments, "cgi") ? arguments.cgi : $cgiScope(); - - return $wildcardDomainMatch($fullDomainString(local.domain), $fullCgiDomainString(local.cgi)); - } - - /** - * Wildcard domain match: domain satisfies wildcard - * - * @domain string to test against e.g *.foo.com - * @origin string to test against e.g bar.foo.com - */ - public boolean function $wildcardDomainMatch(required string domain, required string origin) { - local.rv = false; - local.domainfull = $fullDomainString(arguments.domain); - local.originfull = $fullDomainString(arguments.origin); - - // Do we have a wildcard subdomain? - local.hasWildcard = ListContainsNoCase(local.domainfull, "*", '.') && Len(local.domainfull > 1); - - // If not, is it an exact match? - if (!local.hasWildcard && local.domainfull == local.originfull) { - local.rv = true; - } - - // Loop over domain backwards and test the corresponding position in the other array - if (local.hasWildcard) { - local.domainReversed = ListToArray(Reverse(SpanExcluding(Reverse(local.domainfull), "."))); - local.serverNameReversed = ListToArray(Reverse(SpanExcluding(Reverse(local.originfull), "."))); - local.wildcardPassed = true; - // Check each part with corresponding part in other array - for (local.i = 1; i LTE ArrayLen(local.domainReversed); i = i + 1) { - if (local.domainReversed[i] != local.serverNameReversed[i] && local.domainReversed[i] DOES NOT CONTAIN '*') { - local.wildcardPassed = false; - break; - } - } - local.rv = local.wildcardPassed; - } - - return local.rv; - } - - /** - * Get full domain string from cgi scope: includes protocol and port - * e.g https://www.wheels.dev:443 - * - * @cgi Fake CGI Scope for Testing; will default to normal cgi scope - **/ - public string function $fullCgiDomainString(struct cgi) { - local.cgi = StructKeyExists(arguments, "cgi") ? arguments.cgi : $cgiScope(); - local.server_name = local.cgi.server_name; - local.server_port = local.cgi.server_port; - local.server_protocol = - ( - (StructKeyExists(local.cgi, 'http_x_forwarded_proto') && local.cgi.http_x_forwarded_proto == "https") - || (StructKeyExists(local.cgi, 'server_port_secure') && local.cgi.server_port_secure) - ) - ? "https" : "http"; - return local.server_protocol & '://' & local.server_name & ':' & local.server_port; - } - - /** - * Get full domain string from a passed in string: includes protocol and port - * e.g https://www.wheels.dev -> https://www.wheels.dev:443 - * e.g www.wheels.dev -> http://www.wheels.dev:80 - * - * @domain The string to look at - **/ - public string function $fullDomainString(required string domain) { - local.domain = arguments.domain; - local.protocol = ListFirst(local.domain, "://"); - local.port = ListLast(local.domain, ":"); - - if (!ListFindNoCase("http,https", local.protocol)) { - if (local.port == 443) { - local.protocol = "https"; - } else { - local.protocol = "http"; - } - local.domain = local.protocol & '://' & local.domain; - } - if (!IsNumeric(local.port)) { - if (local.protocol == 'http') { - local.port = 80; - } else if (local.protocol == 'https') { - local.port = 443; - } - local.domain &= ':' & local.port; - } - return local.domain; - } - - /** - * Set CORS Headers: only triggered if application.wheels.allowCorsRequests = true - */ - public void function $setCORSHeaders( - string allowOrigin = "", - string allowCredentials = false, - string allowHeaders = "Origin, Content-Type, X-Auth-Token, X-Requested-By, X-Requested-With", - string allowMethods = "GET, POST, PATCH, PUT, DELETE, OPTIONS", - boolean allowMethodsByRoute = false, - string pathInfo = request.cgi.PATH_INFO, - string scriptName = request.cgi.script_name - ) { - local.incomingOrigin = StructKeyExists(request.wheels.httprequestdata.headers, "origin") ? request.wheels.httprequestdata.headers.origin : false; - - // No origins configured — skip all CORS headers (deny all by default) - if (!Len(arguments.allowOrigin)) { - return; - } - - // Either a wildcard, or if a specific domain is set, we need to ensure the incoming request matches it - if (arguments.allowOrigin == "*") { - $header(name = "Access-Control-Allow-Origin", value = arguments.allowOrigin); - } else { - // Passed value may be a list or just a single entry - local.originArr = ListToArray(arguments.allowOrigin); - - // Is this origin in the allowed Array? - for (local.o in local.originArr) { - if ($wildcardDomainMatch(local.o, local.incomingOrigin)) { - $header(name = "Access-Control-Allow-Origin", value = local.incomingOrigin); - $header(name = "Vary", value = "Origin"); - break; - } - } - } - - // Set Origin, Content-Type, X-Auth-Token, X-Requested-By, X-Requested-With Allow Headers - $header(name = "Access-Control-Allow-Headers", value = arguments.allowHeaders); - - // Either Look up Route specific allowed methods, or just use default - if (arguments.allowMethodsByRoute) { - local.permittedMethods = []; - - // NB this is basically duplicate logic: needs refactoring - if (arguments.pathInfo == arguments.scriptName || arguments.pathInfo == "/" || !Len(arguments.pathInfo)) { - local.path = ""; - } else { - local.path = Right(arguments.pathInfo, Len(arguments.pathInfo) - 1); - } - - // Attempt to match the requested route and only display the allowed methods for that route - // Does this info already exist in scope? It seems silly to have to look it up again - for (local.route in application.wheels.routes) { - // Make sure route has been converted to regular expression. - if (!StructKeyExists(local.route, "regex")) { - local.route.regex = application.wheels.mapper.$patternToRegex(local.route.pattern); - } - - // If route matches regular expression, get the methods - if (ReFindNoCase(local.route.regex, local.path)) { - ArrayAppend(local.permittedMethods, local.route.methods); - } - } - if (ArrayLen(local.permittedMethods)) { - $header(name = "Access-Control-Allow-Methods", value = UCase(ArrayToList(local.permittedMethods, ', '))); - } - } else { - $header(name = "Access-Control-Allow-Methods", value = arguments.allowMethods); - } - - // Only add this header if requested (false is an invalid value) - if (arguments.allowCredentials) { - $header(name = "Access-Control-Allow-Credentials", value = true); - } - } - - /** - * Internal. Returns true when a `wheels.middleware.Cors` instance (or its - * component path) is registered in `application.wheels.middleware`. When it - * is, the dispatch-level Cors middleware is the single source of truth for - * CORS headers and OPTIONS preflight, so the legacy global path - * (`$setCORSHeaders` + the `onRequestStart` OPTIONS abort) must step aside. - * Running both stacks duplicate `Access-Control-Allow-*` headers; a - * duplicate `Access-Control-Allow-Origin` makes browsers reject the - * response per the Fetch spec. Mirrors the detection in - * `Dispatch.$computePreflightCapable()`. (#3114) - */ - public boolean function $corsMiddlewareActive() { - if ( - !StructKeyExists(application, "wheels") - || !StructKeyExists(application.wheels, "middleware") - || !IsArray(application.wheels.middleware) - ) { - return false; - } - for (local.mw in application.wheels.middleware) { - if (IsSimpleValue(local.mw)) { - if (local.mw == "wheels.middleware.Cors") { - return true; - } - } else if (IsObject(local.mw) && IsInstanceOf(local.mw, "wheels.middleware.Cors")) { - return true; - } - } - return false; - } - - /** - * Internal. Logs a one-time warning when the legacy global CORS path is - * suppressed in favour of a registered `wheels.middleware.Cors` instance, - * so operators notice the redundant `allowCorsRequests=true` setting. (#3114) - */ - public void function $warnGlobalCorsDeferred() { - if (StructKeyExists(application.wheels, "$corsGlobalDeferredWarned")) { - return; - } - cflock(name = "wheels.corsGlobalDeferred.#application.applicationName#", type = "exclusive", timeout = 5) { - if (!StructKeyExists(application.wheels, "$corsGlobalDeferredWarned")) { - application.wheels.$corsGlobalDeferredWarned = true; - cflog( - type = "warning", - file = "wheels", - text = "CORS configuration conflict: both allowCorsRequests=true and a wheels.middleware.Cors " - & "instance are active. The legacy global CORS path is deferring to the middleware to avoid " - & "duplicate Access-Control-Allow-* headers. Disable allowCorsRequests once the Cors middleware " - & "is configured. (##3114)" - ); - } - } - } - - /** - * Restore the application scope modified by the test runner - */ - public void function $restoreTestRunnerApplicationScope() { - if (StructKeyExists(request, "wheels") && StructKeyExists(request.wheels, "testRunnerApplicationScope")) { - application.wheels = request.wheels.testRunnerApplicationScope; - } - } - - /** - * Registers a callback function to be invoked when an unhandled error occurs. - * Callbacks receive a single argument: the exception struct. - * Multiple callbacks are invoked in registration order. A failing callback - * is logged and skipped — it will not prevent other callbacks from running. - * Should be called during app initialization, not per-request. - * - * [section: Configuration] - * [category: Error Handling] - * - * @callback A function that accepts an exception struct argument. Must complete quickly — long-running callbacks delay error responses. - */ - public void function registerOnError(required function callback) { - ArrayAppend(application.wheels.onErrorCallbacks, arguments.callback); - } - - /** - * Fires all registered onError callbacks. Each runs in its own try/catch - * so a broken callback cannot suppress other callbacks or break error rendering. - */ - public void function $fireOnErrorCallbacks(required any exception) { - if ( - StructKeyExists(application, "wheels") - && StructKeyExists(application.wheels, "onErrorCallbacks") - && IsArray(application.wheels.onErrorCallbacks) - ) { - for (var cb in application.wheels.onErrorCallbacks) { - try { - cb(arguments.exception); - } catch (any e) { - cflog(text = "onError callback failed: #e.message#", type = "error", file = "wheels-errors"); - } - } - } - } - - /** - * Verifies that mixin-assembled objects satisfy critical interface contracts. - * Runs only in development mode at the end of application bootstrap. - * Checks a subset of essential methods — full verification is done by test specs. - * Logs warnings instead of throwing to avoid blocking app startup. - * Note: the model check is a no-op at startup because models are lazy-loaded - * (application.wheels.models is empty until the first model() call). - * It activates when called later or from tests. - */ - public void function $verifyInterfaceContracts() { - local.issues = []; - - // Check Model interface (requires at least one model to be loaded) - try { - local.modelMethods = [ - "findAll", - "findOne", - "findByKey", - "count", - "exists", - "save", - "valid", - "update", - "delete", - "hasMany", - "belongsTo", - "hasOne", - "validatesPresenceOf" - ]; - if (StructKeyExists(application.wheels, "models") && !StructIsEmpty(application.wheels.models)) { - local.sampleModelName = StructKeyArray(application.wheels.models)[1]; - local.sampleModel = model(local.sampleModelName); - for (local.m in local.modelMethods) { - if (!StructKeyExists(local.sampleModel, local.m)) { - ArrayAppend(local.issues, "Model(#local.sampleModelName#) missing: #local.m#()"); - } - } - } - } catch (any e) { - ArrayAppend(local.issues, "Model contract check failed: #e.message#"); + // User-defined global functions + try { + include "/app/global/functions.cfm"; + } catch (any e) { + if (!$isMissingMappedInclude(e)) { + rethrow; } - - // Check Controller interface try { - local.controllerMethods = [ - "renderView", - "renderPartial", - "renderText", - "redirectTo", - "linkTo", - "urlFor", - "startFormTag", - "endFormTag", - "filters", - "verifies" - ]; - local.params = {controller = "wheels", action = "wheels"}; - local.testController = controller(name = "wheels", params = local.params); - for (local.m in local.controllerMethods) { - if (!StructKeyExists(local.testController, local.m)) { - ArrayAppend(local.issues, "Controller missing: #local.m#()"); - } - } - } catch (any e) { - ArrayAppend(local.issues, "Controller contract check failed: #e.message#"); - } - - // Report issues as warnings - if (ArrayLen(local.issues)) { - local.msg = "Interface contract warnings: " & ArrayToList(local.issues, "; "); - cflog(text = local.msg, type = "warning", file = "wheels-errors"); - if (StructKeyExists(application, "wheels") && application.wheels.showDebugInformation) { - request.wheels.interfaceWarnings = local.issues; - } - } - } - - /** - * Snapshot mtimes of all .cfm files under the app's global include directory. - * - * Used by the bare `?reload=true` path so a developer adding a helper to - * `app/global/*.cfm` does not have to remember the password-gated full reload - * (issue ##2792). - */ - public struct function $snapshotGlobalIncludes(string directory = ExpandPath("/app/global")) { - var snapshot = {}; - if (!DirectoryExists(arguments.directory)) { - return snapshot; - } - var files = DirectoryList(arguments.directory, true, "query", "*.cfm"); - for (var row in files) { - snapshot[row.directory & "/" & row.name] = row.dateLastModified; - } - return snapshot; - } - - /** - * Compare a prior `$snapshotGlobalIncludes` result against the current - * filesystem state and return true if any tracked .cfm file was added, - * removed, or modified. - * - * Paired with `$snapshotGlobalIncludes` to drive the bare `?reload=true` - * soft-reload path in development (issue ##2792). - */ - public boolean function $globalIncludesChanged( - required struct snapshot, - string directory = ExpandPath("/app/global") - ) { - var current = $snapshotGlobalIncludes(directory = arguments.directory); - for (var key in current) { - if (!StructKeyExists(arguments.snapshot, key)) { - return true; - } - if (DateCompare(arguments.snapshot[key], current[key]) != 0) { - return true; - } - } - for (var key in arguments.snapshot) { - if (!StructKeyExists(current, key)) { - return true; - } - } - return false; - } - - /** - * Build the comma-list of public framework helper names that get mixed onto - * every controller (from `wheels.Global` + `wheels.controller.*` + - * `wheels.view.*`). Stored on `application.wheels.protectedControllerMethods` - * and consumed by `$callAction()` to reject URL dispatch to framework - * helpers like `env()`, `model()`, `redirectTo()` (issue ##2844). - * - * Derived from `getMetaData().functions` on each source component, mirroring - * what `$integrateComponents` mixes onto a controller. `$`-prefixed names - * are already gated separately and are excluded here. - */ - public string function $buildProtectedControllerMethods() { - var protectedMethods = ""; - var sources = ["wheels.Global"]; - var mixinPaths = ["wheels.controller", "wheels.view"]; - for (var basePath in mixinPaths) { - var folder = ExpandPath("/" & Replace(basePath, ".", "/", "all")); - if (!DirectoryExists(folder)) { - continue; - } - var files = DirectoryList(folder, false, "name", "*.cfc"); - for (var fileName in files) { - ArrayAppend(sources, basePath & "." & Replace(fileName, ".cfc", "", "all")); - } - } - for (var componentPath in sources) { - var meta = GetMetaData(CreateObject("component", componentPath)); - if (!StructKeyExists(meta, "functions")) { - continue; - } - for (var fn in meta.functions) { - if ( - StructKeyExists(fn, "access") && fn.access == "public" - && Left(fn.name, 1) != "$" - && !ListFindNoCase(protectedMethods, fn.name) - ) { - protectedMethods = ListAppend(protectedMethods, fn.name); - } + include "../../app/global/functions.cfm"; + } catch (any e2) { + if (!$isMissingMappedInclude(e2)) { + rethrow; } - } - return protectedMethods; - } - - /** - * Convert the comma-list returned by `$buildProtectedControllerMethods()` - * into a struct-as-set so `$callAction()` can perform an O(1) - * `StructKeyExists` membership test on the per-request dispatch hot path - * instead of an O(n) `ListFindNoCase` scan over ~100-250 helper names. - * CFML struct keys are case-insensitive by default, preserving the prior - * `ListFindNoCase` semantics (an action named `ENV` is still rejected like - * `env`). Stored on `application.wheels.protectedControllerMethodsLookup` - * alongside the list, which is retained for callers expecting that shape. - */ - public struct function $protectedControllerMethodsLookup(required string methods) { - var lookup = {}; - for (var name in ListToArray(arguments.methods)) { - lookup[name] = true; - } - return lookup; - } - - /** - * Re-evaluate the given global-includes file into `application.wo`'s - * variables/this scope. Invoked from the bare `?reload=true` soft-reload - * when `$globalIncludesChanged` reports drift (issue ##2792). - * - * `include` inside a method body adds function declarations to the - * method's local scope, not the component's outer scope, so we walk - * local for any user-defined functions and copy them onto variables - * and this so they remain callable on `application.wo` across requests. - */ - public void function $reincludeGlobals(string file = "/app/global/functions.cfm") { - // Evaluate the file in a throwaway instance and bind the functions it - // declares onto variables + this. Done via a separate instance (not a - // bare `include` here) because Adobe CF throws "Routines cannot be - // declared more than once" when a `?reload=true` re-includes a file - // whose UDFs are already bound to application.wo — the prior copy in - // our own scope collides with the re-declaration. A fresh scope per - // call sidesteps that; rebinding here is a plain struct assignment, so - // the updated version replaces the old one on every engine. - var reloaded = new wheels.GlobalIncludeLoader().loadFunctions(arguments.file); - for (var key in reloaded) { - variables[key] = reloaded[key]; - this[key] = reloaded[key]; + include "../app/global/functions.cfm"; } } - // User-defined global functions - include "/app/global/functions.cfm"; - // Promote include-injected UDFs from `variables` to `this` so they're // discoverable via struct-iteration on engines (Adobe CF) where only // `this`-scope members are reliably enumerable. Declared methods on @@ -4452,86 +564,4 @@ return local.$wheels; // "The key [...] was not found in the struct. Valid keys are ([VARKEY])". $promoteIncludedGlobalsToThis(); - /** - * Copy include-injected user functions from `variables` onto `this` so - * they remain enumerable on engines (Adobe CF) where struct-iteration - * only reliably surfaces `this`-scope members. Must stay a function: an - * inline `local.X` iterator in the pseudo-constructor materializes - * `variables.local` and shadows method-local `local` on BoxLang. - * - * The promote-key list is memoized in application scope because this runs - * on EVERY instantiation of every Global-derived component (per model row, - * per controller, per Plugins instance) while its input — the function set - * injected by the `/app/global/functions.cfm` include above — is constant - * for the application lifetime. The memo is keyed per concrete class name - * because whether a subclass's own (e.g. private) methods are already - * registered in `variables` at this point in the pseudo-constructor is - * engine-dependent, so the promotable set is not guaranteed identical - * across subclasses. The gate is the cached key itself, never a separate - * done-flag (##2800 lesson), and the cache lives inside - * `application[$appKey()]`, which `?reload=true` rebuilds as a fresh - * struct — so invalidation is structural. When `application` (or the - * Wheels struct in it) is unavailable — CLI/test bootstrap, early - * application start — we fall back to the full scan without memoizing. - */ - public void function $promoteIncludedGlobalsToThis() { - var promoteCache = ""; - var promoteCacheKey = ""; - if (IsDefined("application")) { - var promoteAppKey = $appKey(); - if (StructKeyExists(application, promoteAppKey) && IsStruct(application[promoteAppKey])) { - var classMetadata = GetMetadata(this); - if (IsStruct(classMetadata) && StructKeyExists(classMetadata, "name") && Len(classMetadata.name)) { - promoteCacheKey = classMetadata.name; - if (!StructKeyExists(application[promoteAppKey], "promotedGlobalKeys")) { - application[promoteAppKey].promotedGlobalKeys = {}; - } - promoteCache = application[promoteAppKey].promotedGlobalKeys; - } - } - } - if (IsStruct(promoteCache) && StructKeyExists(promoteCache, promoteCacheKey)) { - // Memoized path: apply the recorded keys with the same guards the - // fresh scan uses. Keys that vanished from `variables` are skipped - // and keys already on `this` are left alone, so a stale entry can - // never promote something the scan would not have. - var cachedKeys = promoteCache[promoteCacheKey]; - var cachedKeyCount = ArrayLen(cachedKeys); - for (var keyIndex = 1; keyIndex <= cachedKeyCount; keyIndex++) { - var promoteKey = cachedKeys[keyIndex]; - if (StructKeyExists(variables, promoteKey) && !StructKeyExists(this, promoteKey)) { - this[promoteKey] = variables[promoteKey]; - } - } - return; - } - var promotedKeys = $scanAndPromoteIncludedGlobals(); - if (IsStruct(promoteCache)) { - // Concurrent first instantiations may both scan and both assign; - // the value is deterministic per class, so last-write-wins is safe. - promoteCache[promoteCacheKey] = promotedKeys; - } - } - - /** - * The full `variables` scan behind `$promoteIncludedGlobalsToThis()`: - * promote every variables-scope custom function that is not already on - * `this`, returning the promoted key names. Also serves as the - * non-memoizing fallback when application scope is unavailable. - */ - public array function $scanAndPromoteIncludedGlobals() { - var promotedKeys = []; - for (var promoteKey in variables) { - if (!isCustomFunction(variables[promoteKey])) { - continue; - } - if (structKeyExists(this, promoteKey)) { - continue; - } - this[promoteKey] = variables[promoteKey]; - ArrayAppend(promotedKeys, promoteKey); - } - return promotedKeys; - } - } diff --git a/vendor/wheels/Job.cfc b/vendor/wheels/Job.cfc index 5d18a25350..6e9f3e93e3 100644 --- a/vendor/wheels/Job.cfc +++ b/vendor/wheels/Job.cfc @@ -268,6 +268,48 @@ component { return local.result; } + /** + * Internal: Turn a persisted `jobClass` string back into a job instance. + * + * `jobClass` is written on enqueue from `GetMetadata(this).name` and read back here as a + * component path, so the round trip depends on that string still resolving — including its + * casing, on a case-sensitive filesystem. Lucee derives the metadata name from the file + * rather than from how the component was instantiated, so it is canonical there; the + * cross-engine guarantee is pinned by JobClassRoundTripSpec rather than assumed. + * + * When it does not resolve, the raw engine error is `component not found` for a class that + * plainly exists on disk, which sends people to look at mappings and deployment. Name the + * real shape of the problem instead: a string read out of a queue row (issue #3351). + * + * @jobClass The component path as persisted in wheels_jobs. + * @jobId The queue row's id, for the error message. Optional. + */ + public any function $instantiateJobClass(required string jobClass, string jobId = "") { + local.rowLabel = Len(arguments.jobId) ? " named by queue row [#arguments.jobId#]" : ""; + try { + local.rv = CreateObject("component", arguments.jobClass); + } catch (any e) { + Throw( + type = "Wheels.JobClassNotFound", + message = "The job class `#arguments.jobClass#`#local.rowLabel# could not be instantiated: #e.message#", + extendedInfo = "This path was persisted to `wheels_jobs.jobClass` when the job was enqueued and is resolved as a component path now. If the file exists, compare its name and directories to the string above CHARACTER BY CHARACTER — component paths are case-sensitive on Linux but not on macOS or Windows, so a casing mismatch resolves in development and fails in production. It also fails if the job class was renamed, moved, or deleted while rows referencing it were still queued." + ); + } + // A job row names something to instantiate and then call perform() on. Anything without + // perform() is not a job, and failing here says so rather than failing later inside the + // job's own execution where it reads as a job bug. Note this narrows but does not close + // the database-string-to-CreateObject shape the issue flags: the actual guarantee is that + // only $enqueueJob writes this column. + if (!StructKeyExists(local.rv, "perform")) { + Throw( + type = "Wheels.InvalidJobClass", + message = "The component `#arguments.jobClass#`#local.rowLabel# is not a job — it has no `perform()` method.", + extendedInfo = "`wheels_jobs.jobClass` must name a component extending `wheels.Job`. Only the framework writes this column; a value that names something else means the row was written by something other than `enqueue()`." + ); + } + return local.rv; + } + /** * Internal: Process a single job row. */ @@ -315,7 +357,7 @@ component { try { // Instantiate and execute the job - local.jobInstance = CreateObject("component", arguments.jobRow.jobClass); + local.jobInstance = $instantiateJobClass(jobClass = arguments.jobRow.jobClass, jobId = arguments.jobRow.id); if (StructKeyExists(local.jobInstance, "baseDelay")) { local.backoffBaseDelay = local.jobInstance.baseDelay; } diff --git a/vendor/wheels/JobWorker.cfc b/vendor/wheels/JobWorker.cfc index 96abbe334e..4f8336f985 100644 --- a/vendor/wheels/JobWorker.cfc +++ b/vendor/wheels/JobWorker.cfc @@ -449,7 +449,12 @@ component { local.hasTenantContext = false; try { - local.jobInstance = CreateObject("component", arguments.jobRow.jobClass); + // Shared with Job.$processJob so both processing paths report an unresolvable + // jobClass the same way (issue #3351) + local.jobInstance = $jobBridge().$instantiateJobClass( + jobClass = arguments.jobRow.jobClass, + jobId = arguments.jobRow.id + ); local.jobData = DeserializeJSON(arguments.jobRow.data); // Restore tenant context if the job was enqueued within a tenant scope and diff --git a/vendor/wheels/Mapper.cfc b/vendor/wheels/Mapper.cfc index 52e420513e..5224c93ffc 100644 --- a/vendor/wheels/Mapper.cfc +++ b/vendor/wheels/Mapper.cfc @@ -379,42 +379,72 @@ component output="false" { * @path The path to get component files from */ private function $integrateComponents(required string path) { - local.basePath = arguments.path; - local.folderPath = expandPath("/#replace(local.basePath, ".", "/", "all")#"); - - // Get a list of all CFC files in the folder - local.fileList = directoryList(local.folderPath, false, "name", "*.cfc"); - for (local.fileName in local.fileList) { - // Remove the file extension to get the component name - local.componentName = replace(local.fileName, ".cfc", "", "all"); - - $integrateFunctions(createObject("component", "#local.basePath#.#local.componentName#")); - } + // The directory scan + per-file createObject + getMetaData, plus the + // public-method/reference resolution, are cached per path (issue #3213). + // The `get`/`controller` exclude-list only applies to NON-wheels.mapper + // sources, so for the wheels.mapper.* components scanned here every public + // method is integrated — exactly what the precomputed publicMethods hold. + local.plan = $componentIntegrationPlan(arguments.path); + local.iEnd = ArrayLen(local.plan); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + $integrateFunctions(local.plan[local.i].instance, local.plan[local.i].publicMethods); + } } /** - * Dynamically mix methods from a given component into this component. - * Only public, non-inherited methods are added. + * Mix a component's methods into this component. The cached path passes the + * pre-resolved public methods (each `{name, ref}`, see + * $componentIntegrationPlan) and assigns them directly. The fallback path — + * used by init() integrating wheels.Global with no cached list — keeps the + * original metadata scan plus the `get`/`controller` exclude-list (#3213). * * @param componentInstance The component instance to integrate methods from. */ - private function $integrateFunctions(required any componentInstance) { - // Get metadata for the component - local.methods = getMetaData(componentInstance).functions; - local.componentName = getMetaData(componentInstance).FULLNAME; + private function $integrateFunctions(required any componentInstance, array publicMethods = []) { + // Cached path: pre-resolved public method references. + if (ArrayLen(arguments.publicMethods)) { + local.iEnd = ArrayLen(arguments.publicMethods); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.m = arguments.publicMethods[local.i]; + variables[local.m.name] = local.m.ref; + this[local.m.name] = local.m.ref; + } + return; + } - // Iterate over the functions in the component + // Fallback (e.g. init() integrating wheels.Global): scan metadata and + // apply the exclude-list against the source's full name. + local.meta = getMetaData(arguments.componentInstance); + local.methods = StructKeyExists(local.meta, "functions") ? local.meta.functions : []; + local.componentName = StructKeyExists(local.meta, "fullName") ? local.meta.fullName : ""; + local.excludeList = "get,controller"; for (local.method in local.methods) { local.functionName = local.method.name; - local.excludeList = "get,controller"; - // Add only public, non-inherited methods excluding specific ones + // Add only public methods, excluding specific ones unless the source is a mapper component. if (local.method.access == "public" && (!listFindNoCase(local.excludeList, local.functionName) || findNoCase("wheels.mapper", local.componentName))) { - // Assign methods to `variables` and `this` variables[local.functionName] = componentInstance[local.functionName]; this[local.functionName] = componentInstance[local.functionName]; } } + // Adobe CF's getMetaData() does not list component-body includes + // (#2790). After the DC7 split those helpers live in + // vendor/wheels/global/*.cfm, so copy any names the metadata scan + // missed. The exclude-list is the same as above. + if (StructKeyExists(arguments.componentInstance, "$frameworkGlobalFunctionNames")) { + local.includeNames = arguments.componentInstance.$frameworkGlobalFunctionNames(); + local.includeCount = ArrayLen(local.includeNames); + for (local.n = 1; local.n <= local.includeCount; local.n++) { + local.functionName = local.includeNames[local.n]; + if ( + !StructKeyExists(this, local.functionName) + && (!ListFindNoCase(local.excludeList, local.functionName) || FindNoCase("wheels.mapper", local.componentName)) + ) { + variables[local.functionName] = arguments.componentInstance[local.functionName]; + this[local.functionName] = arguments.componentInstance[local.functionName]; + } + } + } } } diff --git a/vendor/wheels/Model.cfc b/vendor/wheels/Model.cfc index 6e0973979e..d745bc2dfb 100644 --- a/vendor/wheels/Model.cfc +++ b/vendor/wheels/Model.cfc @@ -573,72 +573,50 @@ component output="false" displayName="Model" extends="wheels.Global"{ * @path The path to get component files from */ private function $integrateComponents(required string path) { - local.basePath = arguments.path; - local.folderPath = expandPath("/#replace(local.basePath, ".", "/", "all")#"); - - // Get a list of all CFC files in the folder - local.fileList = directoryList(local.folderPath, false, "name", "*.cfc"); - for (local.fileName in local.fileList) { - // Remove the file extension to get the component name - local.componentName = replace(local.fileName, ".cfc", "", "all"); - - $integrateFunctions(createObject("component", "#local.basePath#.#local.componentName#")); + // The directory scan + per-file createObject + getMetaData, plus the + // public-method/reference resolution, are cached per path (issue #3213) — + // they are identical for every model instance. Only the reference + // assignment below runs on each materialization. The mixin-override set is + // resolved once per call (empty in the common no-mixins case) so the old + // per-method $willBeOverriddenByMixin function call is gone from the loop. + local.plan = $componentIntegrationPlan(arguments.path); + local.overrideSet = $mixinOverrideSet("model"); + local.iEnd = ArrayLen(local.plan); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + $integrateFunctions(local.plan[local.i].publicMethods, local.overrideSet); } } /** - * Dynamically mix methods from a given component into this component + * Mix a component's pre-resolved public methods (each `{name, ref}`, see + * $componentIntegrationPlan) into this instance. Preserves the original + * semantics: a method that already exists (from inheritance or an + * earlier-integrated component) is also exposed as `super`, and any + * method a plugin/package mixin will override is likewise aliased to + * `super`. `overrideSet` is the precomputed mixin-override name set. */ - private function $integrateFunctions(componentInstance) { - // Get all methods from the given component - local.methods = getMetaData(componentInstance).functions; - - for (local.method in local.methods) { - local.functionName = local.method.name; - - // Only add public, non-inherited methods - if (local.method.access eq "public") { - local.methodExists = structKeyExists(variables, local.method.name) || structKeyExists(this, local.method.name); - - if (!local.methodExists) { - variables[local.functionName] = componentInstance[local.functionName]; - this[local.functionName] = componentInstance[local.functionName]; - } else { - local.superMethodName = "super" & local.functionName; - variables[local.superMethodName] = componentInstance[local.functionName]; - this[local.superMethodName] = componentInstance[local.functionName]; - } - - // Only add super prefix for functions that will be overridden by plugins/mixins - if ($willBeOverriddenByMixin(local.functionName)) { - local.superMethodName = "super" & local.functionName; - variables[local.superMethodName] = componentInstance[local.functionName]; - this[local.superMethodName] = componentInstance[local.functionName]; - } + private function $integrateFunctions(required array publicMethods, required struct overrideSet) { + local.iEnd = ArrayLen(arguments.publicMethods); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.m = arguments.publicMethods[local.i]; + local.name = local.m.name; + local.ref = local.m.ref; + + if (!(StructKeyExists(variables, local.name) || StructKeyExists(this, local.name))) { + variables[local.name] = local.ref; + this[local.name] = local.ref; + } else { + local.superName = "super" & local.name; + variables[local.superName] = local.ref; + this[local.superName] = local.ref; } - } - } - /** - * Check if a function will be overridden by a plugin/mixin - */ - private boolean function $willBeOverriddenByMixin(required string functionName) { - // Check if application and mixins are available - if (!IsDefined("application") || !StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "mixins")) { - return false; - } - - // Check for both "model" and "global" mixins - local.componentTypes = ["model", "global"]; - - for (local.componentType in local.componentTypes) { - if (StructKeyExists(application.wheels.mixins, local.componentType) && - StructKeyExists(application.wheels.mixins[local.componentType], arguments.functionName)) { - return true; + if (StructKeyExists(arguments.overrideSet, local.name)) { + local.superName = "super" & local.name; + variables[local.superName] = local.ref; + this[local.superName] = local.ref; } } - - return false; } /** diff --git a/vendor/wheels/Policy.cfc b/vendor/wheels/Policy.cfc new file mode 100644 index 0000000000..a0cd0ec421 --- /dev/null +++ b/vendor/wheels/Policy.cfc @@ -0,0 +1,113 @@ +/** + * Base Policy class for the Wheels authorization layer (issue #3156). + * + * A policy answers "may this user perform this action on this record?" with one + * method per action (Pundit-style). This base class is DEFAULT-DENY: every + * standard action returns `false` and `scope()` returns a no-rows chain, so an + * app policy must explicitly override a method to grant access. + * + * App policies live in `app/policies/Policy.cfc` and extend the + * app-level `Policy.cfc` stub in the same folder (which extends `wheels.Policy`, + * mirroring how `app/models/Model.cfc` extends `wheels.Model`). Scaffold one + * with `wheels generate policy Post`. + * + * Usage: + * // app/policies/PostPolicy.cfc + * component extends="Policy" { + * public boolean function update() { + * return IsStruct(variables.user) + * && StructKeyExists(variables.user, "id") + * && variables.user.id == variables.record.authorId; + * } + * public any function scope(required any collection) { + * if (IsStruct(variables.user) && StructKeyExists(variables.user, "id")) { + * return arguments.collection.where("authorId", variables.user.id); + * } + * return super.scope(arguments.collection); + * } + * } + * + * Controllers and views consume policies through the `authorize()`, `can()`, + * and `policyScope()` helpers mixed in from `wheels.controller.authorization`. + * + * [section: Authorization] + * [category: Core] + */ +component { + + /** + * Stores the authenticated identity and the record under evaluation. + * + * @user The authenticated identity (typically a struct or model instance), or an empty string for a guest. + * @record The model instance or model class being authorized, or an empty string for headless policies. + */ + public any function init(any user = "", any record = "") { + variables.user = arguments.user; + variables.record = arguments.record; + return this; + } + + /** + * May the user list records? Default-deny — override in your policy to grant. + */ + public boolean function index() { + return false; + } + + /** + * May the user view this record? Default-deny — override in your policy to grant. + */ + public boolean function show() { + return false; + } + + /** + * May the user see the new-record form? Default-deny — override in your policy to grant. + */ + public boolean function new() { + return false; + } + + /** + * May the user create a record? Default-deny — override in your policy to grant. + */ + public boolean function create() { + return false; + } + + /** + * May the user see the edit form for this record? Default-deny — override in your policy to grant. + */ + public boolean function edit() { + return false; + } + + /** + * May the user update this record? Default-deny — override in your policy to grant. + */ + public boolean function update() { + return false; + } + + /** + * May the user delete this record? Default-deny — override in your policy to grant. + */ + public boolean function delete() { + return false; + } + + /** + * Narrows a collection to the records the user may see (used by `policyScope()` + * for `index` actions). Default-deny: returns a no-rows chain. The empty + * `whereIn` sets the query builder's injection-safe always-empty flag (see + * ##2736) without interpolating the property name into SQL, so it composes + * with any model, query-builder chain, or scope chain. Override in your + * policy to widen. + * + * @collection The model class (or chainable query builder / scope chain) to narrow. + */ + public any function scope(required any collection) { + return arguments.collection.whereIn("id", []); + } + +} diff --git a/vendor/wheels/Public.cfc b/vendor/wheels/Public.cfc index 57cb7eaeb9..49a58f434e 100644 --- a/vendor/wheels/Public.cfc +++ b/vendor/wheels/Public.cfc @@ -5,6 +5,24 @@ component output="false" displayName="Internal GUI" extends="wheels.Global" { */ public struct function $init() { include "/wheels/public/helpers.cfm"; + + // The include above declares its UDFs into `variables` only — they never + // reach `this` on Lucee 6, Adobe 2023 or Adobe 2025 (Lucee 7 and BoxLang + // do promote them, which is why the split stayed invisible). Every helper + // in helpers.cfm is declared `public`, and the framework's own views reach + // them through `variables`, so the divergence only bites an external + // caller — `CreateObject("component", "wheels.Public").$init().$$findMatchingRoutes(…)` + // threw "has no function with name" on three of five engines (##3302). + // + // Same problem, same fix as the `/app/global/functions.cfm` include in + // `Global.cfc`'s pseudo-constructor. Call the raw scan rather than + // `$promoteIncludedGlobalsToThis()`: that wrapper memoizes its promote + // list per class in application scope, and the entry for `wheels.Public` + // is written by the pseudo-constructor *before* this include runs — so the + // memoized path would replay a stale, pre-include key list and promote + // nothing. This is the dev-only GUI component, not a request hot path. + $scanAndPromoteIncludedGlobals(); + return this; } @@ -30,7 +48,7 @@ component output="false" displayName="Internal GUI" extends="wheels.Global" { /** * Defense-in-depth: unless the current environment is `development`, * short-circuit the handler with a 404 response before any view is - * included. Called as the first statement of every non-`index` handler in + * included. Called as the first statement of every handler in * this component. */ public void function $blockInProduction() { @@ -192,6 +210,29 @@ component output="false" displayName="Internal GUI" extends="wheels.Global" { return application.wheels.$cliDbTypeCache[local.dsName]; } + /** + * Returns the shared CliBridge instance — the dev-UI / CLI command + * handlers extracted from cli.cfm (issue #2959). CliBridge is stateless + * (only an immutable command->method allowlist lives in its `variables`), + * so one instance is cached on `application.wheels` and shared across + * concurrent requests; `?reload=true` rebuilds `application.wheels` and + * re-creates it. Falls back to a fresh instance during early bootstrap + * (before `application.wheels` exists), mirroring `$componentIntegrationPlan`. + */ + public any function $cliBridge() { + if (!StructKeyExists(application, "wheels")) { + return CreateObject("component", "wheels.public.CliBridge").init(); + } + if (!StructKeyExists(application.wheels, "cliBridge")) { + lock name="wheels.cliBridge.#application.applicationName#" type="exclusive" timeout="10" { + if (!StructKeyExists(application.wheels, "cliBridge")) { + application.wheels.cliBridge = CreateObject("component", "wheels.public.CliBridge").init(); + } + } + } + return application.wheels.cliBridge; + } + /** * Formats the migrator's discovery list for the /wheels/cli dbStatus * command, mapping the migrator's own status field ("migrated" or "") @@ -333,6 +374,7 @@ component output="false" displayName="Internal GUI" extends="wheels.Global" { This is just a proof of concept */ function index() { + $blockInProduction(); include "/wheels/public/views/congratulations.cfm"; return ""; } diff --git a/vendor/wheels/Test.cfc b/vendor/wheels/Test.cfc index cb99cec653..06fe742273 100644 --- a/vendor/wheels/Test.cfc +++ b/vendor/wheels/Test.cfc @@ -759,7 +759,18 @@ component output="false" displayName="Test" extends="wheels.Global"{ if(arrayLen(local.args) == 2){ return invoke(variables, local.functionName[1], variables[local.args[2]]); } else { - // Use the Evaluate function to run Built-in functions + // Built-in functions. No portable call exists here: + // BoxLang has no Evaluate() BIF at all (verified absent + // on 1.11.0 — "The method Evaluate does not exist"), and + // getBoxRuntime() exists only on BoxLang. executeStatement() + // is the faithful equivalent — like Evaluate it takes the + // whole expression string, so neither branch has to + // re-parse the argument list. Function calls resolve at + // runtime, so the BoxLang-only name never has to compile + // on Lucee or Adobe (#3302). + if (StructKeyExists(server, "boxlang")) { + return getBoxRuntime().executeStatement(arguments.expression); + } return Evaluate(arguments.expression); } } diff --git a/vendor/wheels/WheelsTest.cfc b/vendor/wheels/WheelsTest.cfc index 3c05644586..20c6eb5ae1 100644 --- a/vendor/wheels/WheelsTest.cfc +++ b/vendor/wheels/WheelsTest.cfc @@ -54,9 +54,21 @@ component extends="wheels.wheelstest.system.BaseSpec" { /** * Return a configured TestClient instance. * The base URL is auto-detected from the current server port. + * + * @testContext When true (default), send the isolation header + cookie so + * fixture HTTP binds the isolated test application (issue #3374). Pass + * false to address the live application (isolation specs). */ - public any function $testClient() { - return new wheels.wheelstest.TestClient(baseUrl = $getTestBaseUrl()); + public any function $testClient(boolean testContext = true) { + // Do not name this local `client` — that is a reserved CFML scope + // and Lucee throws "client scope is not enabled" (anti-pattern 11). + var httpClient = new wheels.wheelstest.TestClient(baseUrl = $getTestBaseUrl()); + if (arguments.testContext) { + var ctx = new wheels.events.TestContext(); + httpClient.withHeader(ctx.headerName(), "1"); + httpClient.withCookie(ctx.cookieName(), "1"); + } + return httpClient; } /** diff --git a/vendor/wheels/auth/PasswordHasher.cfc b/vendor/wheels/auth/PasswordHasher.cfc new file mode 100644 index 0000000000..efa015094e --- /dev/null +++ b/vendor/wheels/auth/PasswordHasher.cfc @@ -0,0 +1,275 @@ +/** + * Cross-engine password hashing service using PBKDF2-HMAC-SHA256. + * + * Produces and verifies self-describing, modular-crypt-style hashes: + * + * $pbkdf2-sha256$i=$$ + * + * One algorithm, one storage format: derivation goes through the JVM's + * javax.crypto.SecretKeyFactory ("PBKDF2WithHmacSHA256"), so the same + * (password, salt, iterations) always yields the same bytes on Lucee, + * Adobe CF, and BoxLang alike. Hashes are portable across engines and + * engine migrations, and the embedded iteration count lets deployments + * raise the work factor over time (see needsRehash()). + * + * Defaults: 600000 iterations (OWASP 2023+ recommendation for + * PBKDF2-HMAC-SHA256), 16-byte SecureRandom salt, 256-bit derived key. + * + * Passwords are UTF-8 encoded before derivation, so unicode passwords + * round-trip. Empty passwords hash and verify successfully by design — + * minimum-length policy belongs in application-level validations + * (e.g. validatesLengthOf() on the User model), not in the hasher. + * + * Usage: + * // Register during app init (config/services.cfm) + * injector().map("passwordHasher").to("wheels.auth.PasswordHasher").asSingleton(); + * + * // Hashing on signup / password change: + * user.passwordHash = service("passwordHasher").hash(params.password); + * + * // Verifying on login: + * if (service("passwordHasher").verify(params.password, user.passwordHash)) { ... } + * + * // Transparent work-factor upgrades after a successful verify: + * if (service("passwordHasher").needsRehash(user.passwordHash)) { + * user.passwordHash = service("passwordHasher").hash(params.password); + * } + * + * [section: Authentication] + * [category: Core] + */ +component output="false" { + + /** + * Creates a new PasswordHasher. + * + * @iterations PBKDF2 iteration count used by hash() and as the needsRehash() threshold. Must be a positive integer; construction throws Wheels.PasswordHasher.InvalidConfiguration otherwise. Default 600000 (OWASP 2023+). + */ + public PasswordHasher function init(numeric iterations = 600000) { + if (arguments.iterations <= 0 || arguments.iterations != Int(arguments.iterations)) { + throw( + type = "Wheels.PasswordHasher.InvalidConfiguration", + message = "PasswordHasher iterations must be a positive integer.", + extendedInfo = "Received `#arguments.iterations#`. Use the default (600000, the OWASP 2023+ recommendation for PBKDF2-HMAC-SHA256) unless you have measured a different work factor for your hardware." + ); + } + + variables.iterations = arguments.iterations; + variables.algorithmTag = "pbkdf2-sha256"; + variables.saltLengthBytes = 16; + variables.keyLengthBits = 256; + + // Cached Java handles. SecureRandom is documented thread-safe; + // MessageDigest is only used for its static isEqual(). SecretKeyFactory + // is NOT documented thread-safe, so $deriveKey() creates one per call — + // getInstance() cost is noise next to a 600k-iteration derivation. + variables.secureRandom = CreateObject("java", "java.security.SecureRandom").init(); + variables.messageDigest = CreateObject("java", "java.security.MessageDigest"); + + return this; + } + + /** + * Hash a password with a fresh random salt. + * + * Every call generates a new 16-byte SecureRandom salt, so hashing the + * same password twice yields different strings. The empty password is + * accepted by design; enforce minimum-length policy in your model + * validations instead. + * + * @password The plaintext password to hash (UTF-8 encoded before derivation). + * @return Self-describing hash string: $pbkdf2-sha256$i=$$. + */ + public string function hash(required string password) { + local.salt = $randomBytes(variables.saltLengthBytes); + local.derivedKey = $deriveKey( + password = arguments.password, + salt = local.salt, + iterations = variables.iterations, + keyLengthBits = variables.keyLengthBits + ); + + return "$" & variables.algorithmTag + & "$i=" & variables.iterations + & "$" & BinaryEncode(local.salt, "base64") + & "$" & BinaryEncode(local.derivedKey, "base64"); + } + + /** + * Verify a password against a stored hash. + * + * Re-derives the key using the salt and iteration count embedded in the + * stored hash (so hashes created under a different configured iteration + * count still verify) and compares the raw digest bytes in constant time + * via java.security.MessageDigest.isEqual(). + * + * Never throws: malformed, empty, truncated, or unknown-format hashes + * return false. + * + * @password The plaintext password to check. + * @hash The stored hash string produced by hash(). + * @return True if the password matches the stored hash. + */ + public boolean function verify(required string password, required string hash) { + try { + local.parsed = $parseHash(arguments.hash); + if (!local.parsed.valid) { + return false; + } + + local.candidate = $deriveKey( + password = arguments.password, + salt = local.parsed.salt, + iterations = local.parsed.iterations, + keyLengthBits = Len(local.parsed.derivedKey) * 8 + ); + + // Constant-time comparison of the raw digest bytes — never + // compare password hashes with string operators (timing leaks). + return variables.messageDigest.isEqual(local.candidate, local.parsed.derivedKey); + } catch (any e) { + // verify() is a boolean predicate on untrusted input: any parse or + // derivation error means "does not match", never an exception. + return false; + } + } + + /** + * Check whether a stored hash should be re-hashed under the current + * configuration. + * + * Returns true when the stored iteration count is below the configured + * one, or when the hash format/algorithm tag is unrecognized (including + * malformed hashes). Call after a successful verify() and re-hash the + * plaintext to transparently upgrade the work factor. + * + * @hash The stored hash string to inspect. + * @return True if the hash should be regenerated with hash(). + */ + public boolean function needsRehash(required string hash) { + local.parsed = $parseHash(arguments.hash); + if (!local.parsed.valid) { + return true; + } + return local.parsed.iterations < variables.iterations; + } + + /** + * Return the configured iteration count. + */ + public numeric function getIterations() { + return variables.iterations; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Parse a modular-crypt-style hash string into its components. + * + * Returns {valid, iterations, salt, derivedKey} where salt/derivedKey are + * byte arrays. Never throws: any structural problem (wrong segment count, + * unknown algorithm tag, non-numeric or non-positive iterations, invalid + * base64, empty salt/key) yields valid=false. + */ + private struct function $parseHash(required string hash) { + local.parsed = {valid = false, iterations = 0, salt = "", derivedKey = ""}; + + if (!Len(arguments.hash) || Left(arguments.hash, 1) != "$") { + return local.parsed; + } + + // Base64 never contains "$", so a well-formed hash splits into exactly + // four segments (ListToArray drops the leading empty element). + local.segments = ListToArray(arguments.hash, "$"); + if (ArrayLen(local.segments) != 4) { + return local.parsed; + } + + // Algorithm tag is lowercase by modular-crypt convention — compare + // case-sensitively (CFML == is case-insensitive, hence Compare()). + if (Compare(local.segments[1], variables.algorithmTag) != 0) { + return local.parsed; + } + + if (!REFind("^i=[1-9][0-9]*$", local.segments[2])) { + return local.parsed; + } + local.parsed.iterations = Val(ListLast(local.segments[2], "=")); + + try { + local.parsed.salt = BinaryDecode(local.segments[3], "base64"); + local.parsed.derivedKey = BinaryDecode(local.segments[4], "base64"); + } catch (any e) { + return local.parsed; + } + + if (Len(local.parsed.salt) == 0 || Len(local.parsed.derivedKey) == 0) { + return local.parsed; + } + + local.parsed.valid = true; + return local.parsed; + } + + /** + * Derive a PBKDF2-HMAC-SHA256 key for the given password and salt. + * + * Uses javax.crypto.SecretKeyFactory ("PBKDF2WithHmacSHA256"), which the + * JVM converts password characters to UTF-8 bytes for — byte-identical on + * every engine by construction. A fresh factory per call keeps this safe + * under the DI container's singleton scope (SecretKeyFactory instances + * are not documented thread-safe). + */ + private any function $deriveKey( + required string password, + required any salt, + required numeric iterations, + required numeric keyLengthBits + ) { + // Route through java.lang.String explicitly so toCharArray() resolves + // on every engine regardless of how CFML strings are wrapped. + local.passwordChars = CreateObject("java", "java.lang.String").init(arguments.password).toCharArray(); + + local.keySpec = CreateObject("java", "javax.crypto.spec.PBEKeySpec").init( + local.passwordChars, + arguments.salt, + JavaCast("int", arguments.iterations), + JavaCast("int", arguments.keyLengthBits) + ); + + try { + local.factory = CreateObject("java", "javax.crypto.SecretKeyFactory").getInstance("PBKDF2WithHmacSHA256"); + local.secretKey = local.factory.generateSecret(local.keySpec); + + // Do NOT call members on the returned key directly: it is a + // com.sun.crypto.provider.PBKDF2KeyImpl, a JDK-internal class that + // java.base does not open. Adobe 2025's JVM rejects the reflective + // member access with InaccessibleObjectException (its reflection + // layer makes the concrete class's methods accessible en masse). + // Invoke getEncoded() through the exported javax.crypto.SecretKey + // interface instead — public interface methods need no opens. + local.getEncoded = CreateObject("java", "java.lang.Class") + .forName("javax.crypto.SecretKey") + .getMethod("getEncoded", JavaCast("null", "")); + local.derivedKey = local.getEncoded.invoke(local.secretKey, JavaCast("null", "")); + } finally { + // Zero the internal password copy held by the spec. + local.keySpec.clearPassword(); + } + + return local.derivedKey; + } + + /** + * Generate cryptographically secure random bytes. + */ + private any function $randomBytes(required numeric byteCount) { + // Allocate a zeroed byte[] of the right length, then fill it in place. + local.randomBytes = BinaryDecode(RepeatString("00", arguments.byteCount), "hex"); + variables.secureRandom.nextBytes(local.randomBytes); + return local.randomBytes; + } + +} diff --git a/vendor/wheels/channel/DatabaseAdapter.cfc b/vendor/wheels/channel/DatabaseAdapter.cfc index 6c047906f0..4cf431bcf6 100644 --- a/vendor/wheels/channel/DatabaseAdapter.cfc +++ b/vendor/wheels/channel/DatabaseAdapter.cfc @@ -162,13 +162,36 @@ component { if (arguments.maxRows > 0) { // Bounded pass: select the oldest expired ids first, then delete only // those, so the DELETE (the lock-holding statement) stays small and - // indexed even when a large backlog has accumulated + // indexed even when a large backlog has accumulated. The row bound is + // pushed into dialect SQL (TOP / FETCH FIRST / LIMIT) so the database + // does an index-assisted top-n read instead of materializing the whole + // expired backlog and truncating it client-side. + local.candidateSelect = "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC"; + local.candidateSql = $applyRowBound( + sqlText = local.candidateSelect, + dbType = $detectDatabaseType(), + maxRows = arguments.maxRows + ); + // The driver-level maxrows option is only used when the dialect + // rewrite applied nothing — $applyRowBound returns the statement + // unchanged exactly in that case, and there it is the only bound + // available. Everywhere else it is redundant, and redundant is not + // free: on BoxLang the option reaches PgPreparedStatement as + // setLargeMaxRows(), which pgjdbc has never implemented, so the whole + // pass threw and cleanup() reported 0 deleted on postgres and + // cockroachdb (#3302). JobWorker.$claimNext already bounds this way + // and carries a NOTE saying why; this path kept the option as + // belt-and-braces and reintroduced the failure the note warns about. + local.candidateOptions = {datasource: variables.$datasource}; + if (local.candidateSql == local.candidateSelect) { + local.candidateOptions.maxrows = Int(arguments.maxRows); + } local.candidates = queryExecute( - "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC", + local.candidateSql, { cutoff: {value: local.cutoff, cfsqltype: "cf_sql_timestamp"} }, - {datasource: variables.$datasource, maxrows: arguments.maxRows} + local.candidateOptions ); if (local.candidates.recordCount == 0) { return 0; @@ -306,8 +329,12 @@ component { /** * Detect the database type from the datasource via JDBC metadata. * Returns: "oracle", "postgresql", "h2", "mysql", "sqlserver", "sqlite", or "default". + * + * Public with $ prefix (internal naming convention), matching its caller + * $applyRowBound, so a spec can reproduce the dialect the bounded cleanup + * pass actually chose on the engine/database pair it is running against. */ - private string function $detectDatabaseType() { + public string function $detectDatabaseType() { try { cfdbinfo(type="version", datasource="#variables.$datasource#", name="local.info"); local.product = local.info.database_productname; @@ -323,4 +350,51 @@ component { return "default"; } + /** + * Rewrite a candidate SELECT so the row bound is applied in dialect SQL and the + * database can do an index-assisted top-n read instead of materializing every + * matching row and relying on client-side truncation. + * + * - sqlserver: SELECT TOP n ... + * - oracle: ... FETCH FIRST n ROWS ONLY + * - mysql / postgresql / sqlite / h2: ... LIMIT n + * - anything else (incl. "default" when $detectDatabaseType() falls back on a + * cfdbinfo failure): statement UNCHANGED — appending LIMIT would be a syntax + * error on SQL Server/Oracle. Returning the statement unchanged is the signal + * the caller uses to fall back to the driver-level maxrows option, which is + * then the only bound on the read. That option is not portable (BoxLang routes + * it to a pgjdbc method that does not exist), so it is used only here, where + * the alternative is no bound at all. + * + * The bound is hardened with Int() so only a plain integer is ever interpolated + * into the SQL string. A bound of zero or less returns the statement unchanged. + * Public with $ prefix (internal naming convention) so it stays unit-testable. + * + * @sqlText The SELECT statement to bound. + * @dbType Database type as returned by $detectDatabaseType(). + * @maxRows Maximum number of rows the statement may return. + */ + public string function $applyRowBound( + required string sqlText, + required string dbType, + required numeric maxRows + ) { + local.bound = Int(arguments.maxRows); + if (local.bound <= 0) { + return arguments.sqlText; + } + if (arguments.dbType == "sqlserver") { + return ReplaceNoCase(arguments.sqlText, "SELECT ", "SELECT TOP #local.bound# ", "one"); + } + if (arguments.dbType == "oracle") { + return arguments.sqlText & " FETCH FIRST #local.bound# ROWS ONLY"; + } + if (ListFindNoCase("mysql,postgresql,sqlite,h2", arguments.dbType)) { + return arguments.sqlText & " LIMIT #local.bound#"; + } + // Unknown dialect (incl. the "default" cfdbinfo-failure fallback): leave the + // statement alone rather than risk invalid syntax; driver maxrows bounds it. + return arguments.sqlText; + } + } diff --git a/vendor/wheels/controller/authorization.cfc b/vendor/wheels/controller/authorization.cfc new file mode 100644 index 0000000000..0daf3cf209 --- /dev/null +++ b/vendor/wheels/controller/authorization.cfc @@ -0,0 +1,277 @@ +component { + /** + * Authorizes the current user for an action on a record by dispatching to the + * record's policy (`app/policies/Policy.cfc`). Throws + * `Wheels.NotAuthorized` (HTTP 403) when the policy denies, and returns the + * record unchanged when it allows so the call can be inlined: + * + * ``` + * function update() { + * post = authorize(model("Post").findByKey(params.key)); + * post.update(params.post); + * } + * ``` + * + * A missing policy class throws `Wheels.Policy.NotDefined` in development and + * testing (loud, Pundit-style, to catch typos) and silently denies in + * production — the same environment posture as `tableName()` (##3079). A + * policy class that lacks a method for the action denies. + * + * [section: Controller] + * [category: Authorization Functions] + * + * @record The model instance (or model class / model name string) to authorize against. + * @action The policy method to dispatch. Defaults to the current `params.action`, resolved at call time. + */ + public any function authorize(required any record, string action = "") { + local.action = arguments.action; + if (!Len(local.action)) { + if ( + StructKeyExists(variables, "params") + && IsStruct(variables.params) + && StructKeyExists(variables.params, "action") + ) { + local.action = variables.params.action; + } else if ($get("showErrorInformation")) { + Throw( + type = "Wheels.Policy.MissingAction", + message = "authorize() could not resolve an action to authorize.", + extendedInfo = "No `action` argument was passed and `params.action` is not available on this controller. Pass the action explicitly, e.g. `authorize(record=post, action=""update"")`." + ); + } + } + local.modelName = $policyModelName(arguments.record); + local.policy = $policyFor(arguments.record); + local.allowed = false; + if ( + IsObject(local.policy) + && Len(local.action) + && StructKeyExists(local.policy, local.action) + && IsCustomFunction(local.policy[local.action]) + ) { + // Dynamic dispatch via the built-in Invoke() — Adobe CF's compiler + // rejects a direct `local.policy[local.action]()` call outright + // (InvalidIdentifierException at compile time, verified on Adobe + // 2023), and extracting the function reference first drops the + // receiver binding on BoxLang. Invoke(instance, methodName) is the + // cross-engine-proven form (see QueryBuilder/ScopeChain + // onMissingMethod). The StructKeyExists + IsCustomFunction guard + // mirrors the action-dispatch gate in processing.cfc ($callAction). + local.allowed = Invoke(local.policy, local.action); + // A policy method that forgets to return yields null — on Adobe CF a + // null assignment deletes the variable, so re-materialize the deny. + if (IsNull(local.allowed)) { + local.allowed = false; + } + } + if (!IsBoolean(local.allowed) || !local.allowed) { + $notAuthorized(action = local.action, modelName = local.modelName); + } + return arguments.record; + } + + /** + * Non-throwing boolean policy check for conditionals and views (views run in + * the controller's `variables` scope, so `can()` is available in templates + * automatically): + * + * ``` + * ##linkTo(text="Edit", route="editPost", key=post.id)## + * ``` + * + * Returns `false` (deny) for a guest, for an empty record, and for actions the + * policy has no method for. A missing policy class still throws + * `Wheels.Policy.NotDefined` in development/testing so typos fail loud; in + * production it returns `false`. + * + * [section: Controller] + * [category: Authorization Functions] + * + * @action The policy method to check. + * @record The model instance (or model class / model name string) to check against. Empty string denies. + */ + public boolean function can(required string action, any record = "") { + local.policy = $policyFor(arguments.record); + if ( + !IsObject(local.policy) + || !StructKeyExists(local.policy, arguments.action) + || !IsCustomFunction(local.policy[arguments.action]) + ) { + return false; + } + // Dynamic dispatch via Invoke() (see authorize() for the cross-engine + // reasoning). The IsNull guard covers a policy method that forgets to + // return — null deletes the variable on Adobe CF. + local.allowed = Invoke(local.policy, arguments.action); + return !IsNull(local.allowed) && IsBoolean(local.allowed) && local.allowed; + } + + /** + * Narrows a collection to the records the current user may see by delegating + * to the policy's `scope()` method. Returns whatever the policy returns — + * conventionally a chainable finder you keep composing: + * + * ``` + * function index() { + * posts = policyScope(model("Post")).findAll(page = params.page, perPage = 25); + * } + * ``` + * + * Pass the model class first and chain scopes after the call + * (`policyScope(model("Post")).active()`) — a query-builder or scope chain + * that is already in flight cannot be introspected for its model. When the + * policy class is missing, this throws `Wheels.Policy.NotDefined` in + * development/testing and returns a default-deny (no rows) chain in + * production. + * + * [section: Controller] + * [category: Authorization Functions] + * + * @collection The model class to narrow. + */ + public any function policyScope(required any collection) { + local.modelName = $policyModelName(arguments.collection); + if (!Len(local.modelName) && $get("showErrorInformation")) { + Throw( + type = "Wheels.Policy.InvalidCollection", + message = "policyScope() could not derive a model from the passed collection.", + extendedInfo = "Pass the model class first and chain from the result, e.g. `policyScope(model(""Post"")).active().findAll()`. Query-builder and scope chains that are already in flight cannot be passed to policyScope()." + ); + } + local.policy = $policyFor(arguments.collection); + if (!IsObject(local.policy)) { + // Production missing-policy posture: default-deny (no rows). The empty + // whereIn sets the injection-safe always-empty flag (##2736) without + // interpolating the property name into SQL. + return arguments.collection.whereIn("id", []); + } + return local.policy.scope(arguments.collection); + } + + /** + * Internal function. Resolves and instantiates the policy for a record. + * Returns the initialized policy object, or an empty string when no policy + * applies (which callers treat as deny). A resolvable model name whose policy + * file is missing throws `Wheels.Policy.NotDefined` in development/testing + * and returns an empty string (silent deny) in production. + */ + public any function $policyFor(required any record) { + local.modelName = $policyModelName(arguments.record); + if (!Len(local.modelName)) { + return ""; + } + local.className = $policyClassName(local.modelName); + local.policyPath = $get("policyPath"); + local.file = false; + if (DirectoryExists(ExpandPath(local.policyPath))) { + local.file = $fileExistsNoCase(ExpandPath(local.policyPath & "/" & local.className & ".cfc")); + } + if (IsBoolean(local.file) && !local.file) { + if ($get("showErrorInformation")) { + Throw( + type = "Wheels.Policy.NotDefined", + message = "No policy found for the `#local.modelName#` model.", + extendedInfo = "Create `#local.policyPath#/#local.className#.cfc` (e.g. by running `wheels generate policy #local.modelName#`) with one method per action to grant. In production a missing policy silently denies instead of throwing." + ); + } + return ""; + } + local.componentPath = ListChangeDelims(local.policyPath, ".", "/") & "." & SpanExcluding(local.file, "."); + local.policy = CreateObject("component", local.componentPath); + local.policy.init(user = $currentUserForPolicy(), record = arguments.record); + return local.policy; + } + + /** + * Internal function. Derives the model name a policy should be resolved for: + * model instances and model classes report their class model name, strings + * pass through (headless / by-name checks), and everything else — including + * the boolean `false` a missed finder returns — yields an empty string. + */ + public string function $policyModelName(required any record) { + if (IsBoolean(arguments.record)) { + return ""; + } + if (IsSimpleValue(arguments.record)) { + return Trim(arguments.record); + } + if (IsObject(arguments.record) && StructKeyExists(arguments.record, "$classData")) { + local.classData = arguments.record.$classData(); + if (StructKeyExists(local.classData, "modelName")) { + return local.classData.modelName; + } + } + return ""; + } + + /** + * Internal function. Maps a model name to its conventional policy class name + * (`Post` -> `PostPolicy`). + */ + public string function $policyClassName(required string modelName) { + return capitalize(arguments.modelName) & "Policy"; + } + + /** + * Internal function. Resolves the identity policies are evaluated against, in + * order: (1) the DI service registered as `currentUser` when present, (2) the + * first registered authenticator strategy that exposes a `currentUser()` + * method (e.g. `wheels.auth.SessionStrategy`) and reports a non-empty + * principal, (3) an empty string (guest). Apps customize by registering the + * `currentUser` DI service or by overriding this method on their base + * controller. + */ + public any function $currentUserForPolicy() { + // 1. Explicit DI registration wins. + try { + if (IsDefined("application.wheelsdi") && application.wheelsdi.containsInstance("currentUser")) { + return application.wheelsdi.getInstance("currentUser"); + } + } catch (any e) { + // A broken resolver must not turn every request into a 500 — fall through to the next seam. + } + // 2. A configured authenticator whose strategy can report the current user. + try { + if (IsDefined("application.wheelsdi") && application.wheelsdi.containsInstance("authenticator")) { + local.authenticator = application.wheelsdi.getInstance("authenticator"); + local.strategyNames = local.authenticator.getStrategyNames(); + for (local.strategyName in local.strategyNames) { + local.strategy = local.authenticator.getStrategy(local.strategyName); + if (StructKeyExists(local.strategy, "currentUser")) { + local.candidate = local.strategy.currentUser(); + if (IsStruct(local.candidate) && !StructIsEmpty(local.candidate)) { + return local.candidate; + } + } + } + } + } catch (any e) { + // Session scope unavailable or authenticator misconfigured — treat as guest. + } + // 3. Guest. + return ""; + } + + /** + * Internal function. Surfaces a policy denial as HTTP 403, mirroring how + * `$throwErrorOrShow404Page()` wires `Wheels.RecordNotFound` to 404: the + * status header is committed first, then development/testing throw + * `Wheels.NotAuthorized` (re-asserted to 403 by the onError status mapping in + * `wheels.events.EventMethods`) while production renders a minimal body and + * aborts so no policy detail leaks. + */ + public void function $notAuthorized(required string action, string modelName = "") { + $header(statusCode = 403); + if ($get("showErrorInformation")) { + local.target = Len(arguments.modelName) ? " on `#arguments.modelName#`" : ""; + Throw( + type = "Wheels.NotAuthorized", + message = "Not authorized to perform the `#arguments.action#` action#local.target#.", + extendedInfo = "The resolved policy denied this action (policies are default-deny). Override the `#arguments.action#` method in the policy to grant access. This error maps to HTTP 403." + ); + } else { + WriteOutput("Forbidden"); + abort; + } + } +} diff --git a/vendor/wheels/controller/csrf.cfc b/vendor/wheels/controller/csrf.cfc index 36b1ae197b..d0a6f9f1f8 100644 --- a/vendor/wheels/controller/csrf.cfc +++ b/vendor/wheels/controller/csrf.cfc @@ -256,6 +256,8 @@ component { // State lives in a struct because local assignments made inside catch blocks // do not persist after the catch on BoxLang. local.state = {decrypted = ""}; + local.legacyAvailable = application.wheels.csrfCookieEncryptionAlgorithm != "AES"; + try { local.state.decrypted = Decrypt( arguments.encryptedValue, @@ -264,22 +266,56 @@ component { application.wheels.csrfCookieEncryptionEncoding ); } catch (any e) { - if (application.wheels.csrfCookieEncryptionAlgorithm != "AES") { - try { - local.state.decrypted = Decrypt( - arguments.encryptedValue, - arguments.encryptionKey, - "AES", - application.wheels.csrfCookieEncryptionEncoding - ); - } catch (any legacyDecryptError) { - // Undecryptable with either algorithm — treat as a corrupted cookie. + // fall through to the legacy attempt below + } + + // "Did not throw" is not the same as "decrypted correctly". Decrypting a + // bare-AES (ECB) ciphertext under AES/CBC/PKCS5Padding throws only when the + // trailing plaintext bytes fail padding validation, and they pass by chance + // roughly 1 time in 256 — so Decrypt() returns garbage, the legacy fallback + // never runs, and a perfectly good legacy cookie reads as corrupted (issue + // #3361). AES/GCM/NoPadding is authenticated and does reliably throw, so this + // only ever bit the engines that fall back to CBC. + // + // Checking the RESULT closes that window. This cookie's plaintext is always the + // JSON written by $generateCookieAuthenticityToken(), so anything else means we + // decrypted it with the wrong algorithm — which is exactly when the legacy + // attempt should still run. + if (local.legacyAvailable && !$isCsrfCookiePayload(local.state.decrypted)) { + try { + local.legacyDecrypted = Decrypt( + arguments.encryptedValue, + arguments.encryptionKey, + "AES", + application.wheels.csrfCookieEncryptionEncoding + ); + // Only prefer the legacy result if it actually looks like the payload; + // otherwise keep whatever the configured algorithm produced so a + // genuinely corrupt cookie is not reported differently than before. + if ($isCsrfCookiePayload(local.legacyDecrypted)) { + local.state.decrypted = local.legacyDecrypted; } + } catch (any legacyDecryptError) { + // Undecryptable with either algorithm — treat as a corrupted cookie. } } + return local.state.decrypted; } + /** + * Internal function. + * Whether a decrypted string looks like the CSRF cookie payload rather than the + * garbage a wrong-algorithm decrypt can return without throwing. + * + * `$generateCookieAuthenticityToken()` always writes `SerializeJSON({sessionId, + * authenticityToken})`, so JSON-ness is an invariant of this cookie, not an + * assumption about it. The caller re-checks the same thing before deserializing. + */ + public boolean function $isCsrfCookiePayload(required string value) { + return Len(arguments.value) > 0 && IsJSON(arguments.value); + } + /** * Internal function. */ diff --git a/vendor/wheels/databaseAdapters/Abstract.cfc b/vendor/wheels/databaseAdapters/Abstract.cfc index e3be1874bd..e722de73f0 100755 --- a/vendor/wheels/databaseAdapters/Abstract.cfc +++ b/vendor/wheels/databaseAdapters/Abstract.cfc @@ -113,7 +113,7 @@ component extends="wheels.migrator.Base"{ } // what's the purpose of this? - public boolean function optionsIncludeDefault(string type, string default = "", boolean allowNull = true) { + public boolean function optionsIncludeDefault(string type, default = "", boolean allowNull = true) { return true; } diff --git a/vendor/wheels/databaseAdapters/Base.cfc b/vendor/wheels/databaseAdapters/Base.cfc index 1f102c4fa4..255820bc80 100755 --- a/vendor/wheels/databaseAdapters/Base.cfc +++ b/vendor/wheels/databaseAdapters/Base.cfc @@ -164,15 +164,23 @@ component output=false extends="wheels.Global"{ local.columnList = ""; if (local.startPar > 1 && local.endPar > local.startPar) { local.rawColumns = Mid(arguments.sql, local.startPar, (local.endPar - local.startPar)); - if ($isBoxLangEngine()) { - // BoxLang's ReplaceList behaves differently — use regex to parse the column names. - local.columnList = REReplace(local.rawColumns, "\s*,\s*", ",", "all"); - local.columnList = REReplace(local.columnList, "[\r\n]", "", "all"); - local.columnList = Trim(local.columnList); - } else { - // Original Lucee / Adobe CF behavior. - local.columnList = ReplaceList(local.rawColumns, "#Chr(10)#,#Chr(13)#, ", ",,"); - } + // One implementation for every engine. This used to fork on + // $isBoxLangEngine(), with the ReplaceList form kept for Lucee/Adobe — + // but BoxLang's ReplaceList drops the comma delimiters themselves, so + // "id,name,age" came back as "idnameage" on any code path that reached + // that branch on BoxLang. BaseProbe hard-codes $isBoxLangEngine() to + // false, so the unit spec drove exactly that branch on the boxlang legs + // and failed on all five databases (#3302) while the sibling spec — which + // sets boxlangMode=true — passed. Collapsing the fork removes both the + // engine-dependent behaviour and the test-double trap. + // + // The regex form is also the more correct of the two: ReplaceList + // stripped every space, mangling quoted identifiers that legitimately + // contain one (e.g. `[order date]`), whereas \s*,\s* only collapses + // whitespace adjacent to the delimiters. + local.columnList = REReplace(local.rawColumns, "\s*,\s*", ",", "all"); + local.columnList = REReplace(local.columnList, "[\r\n]", "", "all"); + local.columnList = Trim(local.columnList); } // Strip identifier quotes from the column list for comparison. return $stripIdentifierQuotes(local.columnList); diff --git a/vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc b/vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc index 09cbccfdda..bc1c9166fc 100755 --- a/vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc +++ b/vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc @@ -78,7 +78,7 @@ component extends="wheels.databaseAdapters.Abstract" { * `vendor/wheels/tests/specs/migrator/addColumnOptionsSpec.cfc` — keep * this list and that spec aligned. See #2742. */ - public boolean function optionsIncludeDefault(string type, string default = "", boolean allowNull = true) { + public boolean function optionsIncludeDefault(string type, default = "", boolean allowNull = true) { if (ListFindNoCase("text,mediumtext,longtext,float", arguments.type)) { return false; } else { diff --git a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc index 1d4e2edf35..61176c5776 100755 --- a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc +++ b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc @@ -279,17 +279,32 @@ component extends="wheels.databaseAdapters.Base" output=false { } /** - * Oracle bulk insert using `INSERT ALL INTO ... SELECT 1 FROM dual`. + * Oracle bulk insert using `INSERT INTO t (cols) SELECT ... FROM dual UNION ALL ...`. * - * The default Base adapter shape — `INSERT INTO t (cols) VALUES (?,?), (?,?), ...` - * (SQL standard table value constructor) — was rejected on Oracle 23 with - * `ORA: returning clause is not allowed with INSERT and Table Value Constructor`. - * The CFML engine's `cfquery` for INSERT statements implicitly sets - * `Statement.RETURN_GENERATED_KEYS`, which the Oracle JDBC driver translates into a - * RETURNING clause — and Oracle 23 does not permit RETURNING with multi-row VALUES. + * Two Oracle constraints shape this, and satisfying only the first is what the + * previous `INSERT ALL` form did. + * + * 1. The default Base adapter shape — `INSERT INTO t (cols) VALUES (?,?), (?,?)` + * (SQL standard table value constructor) — was rejected on Oracle 23 with + * `ORA: returning clause is not allowed with INSERT and Table Value + * Constructor`. The CFML engine's `cfquery` implicitly sets + * `Statement.RETURN_GENERATED_KEYS` on INSERTs, which the Oracle JDBC driver + * translates into a RETURNING clause, and Oracle 23 does not permit RETURNING + * with multi-row VALUES (#2745). + * + * 2. In a multitable insert (`INSERT ALL`), Oracle evaluates each row's default + * expressions ONCE PER ROW OF THE DRIVING QUERY and shares the result across + * every INTO clause. The driving query was `SELECT 1 FROM dual` — a single row + * — so every INTO received the SAME identity value, and any table with an + * identity or sequence-backed primary key got a duplicate-key violation on the + * second record: `ORA-00001 ... row with column values (ID:1) already exists`. + * insertAll() could never insert more than one row into such a table (#3302). + * + * `INSERT ... SELECT ... UNION ALL` satisfies both: it is not a table value + * constructor, and its driving query returns one row per record, so the identity + * default is evaluated per row. It is also the shape `$upsertSQL` below already + * uses for its MERGE source, including the alias-the-first-branch-only detail. * - * `INSERT ALL` is the Oracle-idiomatic multi-row insert form, doesn't trigger the - * RETURNING-clause expansion, and works on every Oracle version Wheels targets. * Uses parameterized values via `$buildBulkParam` — never interpolates user data * into SQL. */ @@ -312,11 +327,14 @@ component extends="wheels.databaseAdapters.Base" output=false { local.colList &= $quoteIdentifier(local.col); } - ArrayAppend(local.sql, "INSERT ALL"); + ArrayAppend(local.sql, "INSERT INTO #arguments.tableName# (#local.colList#) "); local.propCount = ArrayLen(arguments.validProperties); for (local.r = arguments.batchStart; local.r <= arguments.batchEnd; local.r++) { - ArrayAppend(local.sql, " INTO #arguments.tableName# (#local.colList#) VALUES ("); + if (local.r > arguments.batchStart) { + ArrayAppend(local.sql, " UNION ALL "); + } + ArrayAppend(local.sql, "SELECT "); for (local.p = 1; local.p <= local.propCount; local.p++) { if (local.p > 1) { ArrayAppend(local.sql, ", "); @@ -328,12 +346,15 @@ component extends="wheels.databaseAdapters.Base" output=false { propName = local.propName, propertyInfo = arguments.propertyInfo )); + // Only the first branch needs column aliases; the rest of the + // UNION ALL inherits them. Same rule as $upsertSQL's MERGE source. + if (local.r == arguments.batchStart) { + ArrayAppend(local.sql, " AS " & $quoteIdentifier(arguments.columns[local.p])); + } } - ArrayAppend(local.sql, ")"); + ArrayAppend(local.sql, " FROM dual"); } - ArrayAppend(local.sql, " SELECT 1 FROM dual"); - return local.sql; } diff --git a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc index 4666a5466e..adaa892d6d 100755 --- a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc +++ b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc @@ -90,10 +90,47 @@ component extends="wheels.databaseAdapters.Base" output=false { case "geography": local.rv = "cf_sql_other"; break; + default: + // Without this branch `local.rv` is never assigned and the return throws + // `key [RV] doesn't exist` — an error that names nothing useful and reads + // like a framework bug. The classic source was catalog bleed: a table whose + // name collides with an `information_schema` view picked up phantom columns + // typed `"information_schema"."sql_identifier"` (issue #3349, fixed in + // `$getColumnInfo()` below). Anything reaching here now is a genuinely + // unmapped PostgreSQL type, so say so. + Throw( + type = "Wheels.UnknownColumnType", + message = "The PostgreSQL column type `#arguments.type#` is not mapped to a CFML SQL type.", + extendedInfo = "Add a case for `#arguments.type#` to `$getType()` in `vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc`. If the type name looks schema-qualified (e.g. `""information_schema"".""sql_identifier""`), the column is not yours — it came from a catalog view sharing your table's name." + ); } return local.rv; } + /** + * Override Base adapter's function. + * + * `cfdbinfo(type="columns")` applies no schema restriction, so JDBC matches the table name + * across every schema on the connection. PostgreSQL and YugabyteDB both ship ANSI + * `information_schema` views named `sequences`, `tables`, `columns`, `views`, `triggers` + * and more, so an application table named `sequences` collected a second batch of columns + * from `information_schema.sequences` — typed `"information_schema"."sql_identifier"`, + * which nothing in `$getType()` matched (issue #3349). Via `CockroachDBModel`, the + * `crdb_internal` and `pg_extension` view names collide the same way. + * + * Filtering here rather than in `$getColumns()` keeps the work behind the + * `cacheDatabaseSchema` memo that `$getColumns()` wraps around this call — once per + * datasource+table per application lifetime instead of on every read. + */ + public query function $getColumnInfo( + required string table, + required string datasource, + required string username, + required string password + ) { + return $excludeSystemSchemaRows(columns = super.$getColumnInfo(argumentCollection = arguments)); + } + /** * Call functions to make adapter specific changes to arguments before executing query. */ diff --git a/vendor/wheels/databaseAdapters/SQLite/SQLiteMigrator.cfc b/vendor/wheels/databaseAdapters/SQLite/SQLiteMigrator.cfc index 5241e45662..c45f84ad50 100755 --- a/vendor/wheels/databaseAdapters/SQLite/SQLiteMigrator.cfc +++ b/vendor/wheels/databaseAdapters/SQLite/SQLiteMigrator.cfc @@ -76,7 +76,7 @@ component extends="wheels.databaseAdapters.Abstract" { /** * In SQLite, most types can have default values, except BLOB. */ - public boolean function optionsIncludeDefault(string type, string default = "", boolean allowNull = true) { + public boolean function optionsIncludeDefault(string type, default = "", boolean allowNull = true) { if (ListFindNoCase("blob", arguments.type)) { return false; } diff --git a/vendor/wheels/engineAdapters/Base.cfc b/vendor/wheels/engineAdapters/Base.cfc index 0d716c1af7..df0d911d59 100644 --- a/vendor/wheels/engineAdapters/Base.cfc +++ b/vendor/wheels/engineAdapters/Base.cfc @@ -70,6 +70,24 @@ component output="false" { return true; } + /** + * Aggregates the adapter's capability probes into a plain-data struct, + * computed lazily on first call and cached in the variables scope for + * the adapter's lifetime (adapters are application-scoped singletons). + * Plain booleans only — never add function references here: the struct + * may end up in application scope, which rejects function members on + * Adobe CF. + */ + public struct function getCapabilities() { + if (!StructKeyExists(variables, "capabilities")) { + variables.capabilities = { + cfcache: supportsCfcache(), + imageInfo: supportsImageInfo() + }; + } + return variables.capabilities; + } + // --- Response / PageContext --- /** @@ -258,6 +276,17 @@ component output="false" { // --- Image Handling --- + /** + * Returns true if the engine can read image metadata (width/height) via + * the adapter's imageInfo() implementation. Engines without an image + * runtime (e.g. RustCFML — no JVM, so no AWT/ImageIO) override this to + * false so callers like $imageTag() skip the dimension probe and render + * the tag without dimensions instead of erroring. + */ + public boolean function supportsImageInfo() { + return true; + } + /** * Gets image information for a given source file. * BoxLang uses ImageRead+ImageInfo; Lucee/Adobe use cfimage action=info. diff --git a/vendor/wheels/engineAdapters/RustCFML/RustCFMLAdapter.cfc b/vendor/wheels/engineAdapters/RustCFML/RustCFMLAdapter.cfc index 7b971013a7..f1fc010111 100644 --- a/vendor/wheels/engineAdapters/RustCFML/RustCFMLAdapter.cfc +++ b/vendor/wheels/engineAdapters/RustCFML/RustCFMLAdapter.cfc @@ -17,13 +17,34 @@ component extends="wheels.engineAdapters.Base" output="false" { } /** - * RustCFML (as of 0.41.0) does not implement the `cfcache` built-in. - * Returning false makes Wheels skip its cfcache-backed template/static - * cache (see Global.cfc $cache) so the framework boots and serves - * cacheless-but-working instead of erroring on the missing built-in. + * RustCFML implements the `cfcache` built-in as of v0.417. Earlier + * builds lacked it and this override returned false so Wheels degraded + * its cfcache-backed template/static cache (see Global.cfc $cache) to a + * no-op. The override is kept (returning the Base default) purely as a + * record of the resolved divergence. */ public boolean function supportsCfcache() { + return true; + } + + /** + * RustCFML has no JVM and therefore no AWT/ImageIO-backed image runtime, + * so Base.cfc's cfimage action="info" implementation is unavailable. + * Callers such as $imageTag() use this to skip the width/height probe + * and render the tag without dimensions instead of erroring. + */ + public boolean function supportsImageInfo() { return false; } + /** + * Defensive fallback for callers that reach imageInfo() despite + * supportsImageInfo() being false: returns the same struct shape + * Base.cfc's cfimage action="info" produces, with width/height 0 + * meaning "unknown" ($imageTag only emits the attributes when > 0). + */ + public struct function imageInfo(required string source) { + return {width: 0, height: 0, source: arguments.source}; + } + } diff --git a/vendor/wheels/events/EventMethods.cfc b/vendor/wheels/events/EventMethods.cfc index ec055aaaca..a6a2424c4b 100644 --- a/vendor/wheels/events/EventMethods.cfc +++ b/vendor/wheels/events/EventMethods.cfc @@ -81,21 +81,30 @@ component extends="wheels.Global" implements="wheels.interfaces.events.EventHand // ViewNotFound, etc) is a 404, as is `Wheels.ActionNotAllowed` // — the action-dispatch gate blocks framework helpers and // $-prefixed internals by treating them as missing actions - // (#2845, #3075); everything else is a 500. + // (#2845, #3075); `Wheels.NotAuthorized` — a policy denial + // from the authorization layer (#3156) — is a 403; everything + // else is a 500. // Set the status BEFORE writing the body so the response // header is committed at the right code regardless of // when the servlet engine flushes (HTML-format Wheels // errors used to render with HTTP 200 because no // $header(statusCode=...) fired before the body was // written — see GH #2319). Note: $throwErrorOrShow404Page - // already calls $header(statusCode=404) before throwing, - // but onError reaches us via Application.cfc which can - // reset the response, so we re-assert the status here. + // already calls $header(statusCode=404) before throwing + // (and the authorization mixin's $notAuthorized() calls + // $header(statusCode=403)), but onError reaches us via + // Application.cfc which can reset the response, so we + // re-assert the status here. if ( StructKeyExists(local.wheelsError, "type") && ReFindNoCase("^Wheels\.([A-Za-z]*NotFound|ActionNotAllowed)$", local.wheelsError.type) ) { $header(statusCode = 404); + } else if ( + StructKeyExists(local.wheelsError, "type") + && ReFindNoCase("^Wheels\.NotAuthorized$", local.wheelsError.type) + ) { + $header(statusCode = 403); } else { $header(statusCode = 500); } diff --git a/vendor/wheels/events/TestContext.cfc b/vendor/wheels/events/TestContext.cfc new file mode 100644 index 0000000000..3afcfddc9e --- /dev/null +++ b/vendor/wheels/events/TestContext.cfc @@ -0,0 +1,119 @@ +/** + * Helpers for the web test-runner's isolated CFML application context (issue #3374). + * + * A request-scoped config overlay cannot re-bake dialect adapters, model + * caches, or routes (blockers B1–B9 on #3025). The supported isolation + * model is a second application name, derived in Application.cfc's + * constructor via events/testcontext.cfm. This CFC is the runtime twin + * of that include: same suffix / header / cookie names, unit-testable + * without booting a second application. + * + * Do not instantiate this from Application.cfc's constructor — `this.mappings` + * is not guaranteed to be registered yet. The .cfm include inlines the + * same checks. + */ +component { + + /** + * Suffix appended to this.name for isolated test requests. + */ + public string function applicationNameSuffix() { + return "_wheelsTest"; + } + + /** + * HTTP header TestClient / ParallelRunner / Playwright send so fixture + * and browser requests (which are NOT /wheels/core/tests) bind the + * isolated application. CGI key is http_x_wheels_test_context. + */ + public string function headerName() { + return "X-Wheels-Test-Context"; + } + + /** + * CGI struct key for headerName() after the engine's CGI mapping. + */ + public string function cgiHeaderKey() { + return "http_x_wheels_test_context"; + } + + /** + * Cookie name (backup for Playwright follow-on navigations). + */ + public string function cookieName() { + return "WHEELS_TEST_CONTEXT"; + } + + /** + * True when applicationName already carries the isolation suffix. + */ + public boolean function isIsolatedApplicationName(required string applicationName) { + var suffix = applicationNameSuffix(); + var nameLen = Len(arguments.applicationName); + var suffixLen = Len(suffix); + if (nameLen < suffixLen) { + return false; + } + return Right(arguments.applicationName, suffixLen) == suffix; + } + + /** + * Return applicationName with the isolation suffix, idempotent. + */ + public string function isolatedApplicationName(required string applicationName) { + if (isIsolatedApplicationName(arguments.applicationName)) { + return arguments.applicationName; + } + return arguments.applicationName & applicationNameSuffix(); + } + + /** + * True when this request should bind the isolated test application. + * + * Markers (any one is enough): + * - URL path contains /wheels/core/tests or /wheels/app/tests + * - X-Wheels-Test-Context header (CGI http_x_wheels_test_context) + * - WHEELS_TEST_CONTEXT cookie + * + * Parameter names avoid reserved CGI/cookie/url/request scopes + * (anti-pattern 11 / invariant 15). + */ + public boolean function requestIsTestContext(struct cgiScope = {}, struct cookieScope = {}) { + var haystack = $cgiHaystack(arguments.cgiScope); + if (FindNoCase("/wheels/core/tests", haystack) || FindNoCase("/wheels/app/tests", haystack)) { + return true; + } + + var headerKey = cgiHeaderKey(); + if (StructKeyExists(arguments.cgiScope, headerKey) && Len(ToString(arguments.cgiScope[headerKey]))) { + return true; + } + + var cName = cookieName(); + if (StructKeyExists(arguments.cookieScope, cName) && Len(ToString(arguments.cookieScope[cName]))) { + return true; + } + + return false; + } + + /** + * Concatenate the CGI fields that can carry the runner path under + * rewrite, subdirectory, and query-string front-controller shapes. + */ + public string function $cgiHaystack(required struct cgiScope) { + var haystack = ""; + var keys = "path_info,script_name,query_string,request_url,http_url"; + var i = 0; + var key = ""; + var keyCount = ListLen(keys); + for (i = 1; i <= keyCount; i++) { + key = ListGetAt(keys, i); + if (StructKeyExists(arguments.cgiScope, key)) { + haystack &= " " & ToString(arguments.cgiScope[key]); + } + } + return haystack; + } + +} diff --git a/vendor/wheels/events/init/views.cfm b/vendor/wheels/events/init/views.cfm index 8a63c18b32..ca21f9bb07 100644 --- a/vendor/wheels/events/init/views.cfm +++ b/vendor/wheels/events/init/views.cfm @@ -14,6 +14,7 @@ application.$wheels.filePath = "files"; application.$wheels.imagePath = "images"; application.$wheels.javascriptPath = "javascripts"; application.$wheels.modelPath = "/app/models"; +application.$wheels.policyPath = "/app/policies"; application.$wheels.pluginPath = "/plugins"; application.$wheels.pluginComponentPath = "/plugins"; application.$wheels.packagePath = "/vendor"; diff --git a/vendor/wheels/events/onapplicationstart.cfc b/vendor/wheels/events/onapplicationstart.cfc index 30195be304..ad3c01f9f2 100644 --- a/vendor/wheels/events/onapplicationstart.cfc +++ b/vendor/wheels/events/onapplicationstart.cfc @@ -39,26 +39,9 @@ component { // Check and store server engine name, throw error if using a version that we don't support. // Note: this must NOT be chained to the reloadPassword carryover above with `else` — // engine detection has to run unconditionally or serverVersion is never set. - if (StructKeyExists(server, "boxlang")) { - application.$wheels.serverName = "BoxLang"; - application.$wheels.serverVersion = server.boxlang.version; - } else if (StructKeyExists(server, "lucee")) { - application.$wheels.serverName = "Lucee"; - application.$wheels.serverVersion = server.lucee.version; - } else if ( - StructKeyExists(server, "coldfusion") - && StructKeyExists(server.coldfusion, "productName") - && server.coldfusion.productName == "RustCFML" - ) { - // RustCFML reports itself via server.coldfusion.productName (no - // server.lucee / server.boxlang), so it must be detected before - // the Adobe fallback below or it gets misclassified as Adobe CF. - application.$wheels.serverName = "RustCFML"; - application.$wheels.serverVersion = server.coldfusion.productVersion; - } else { - application.$wheels.serverName = "Adobe ColdFusion"; - application.$wheels.serverVersion = server.coldfusion.productVersion; - } + local.engine = $detectEngine(serverScope = server); + application.$wheels.serverName = local.engine.serverName; + application.$wheels.serverVersion = local.engine.serverVersion; application.$wheels.serverVersionMajor = ListFirst(application.$wheels.serverVersion, ".,"); // Instantiate the engine adapter for centralized cross-engine behavior. @@ -116,6 +99,13 @@ component { // machinery ($addToCache / $cacheCount) walks and dereferences `.expiresAt` // on. Putting schema queries under `cache.*` makes the cull throw. application.$wheels.schemaColumnCache = {}; + // Per-app mixin-integration plans (see Global.cfc $componentIntegrationPlan). + // Caches the directory scan + per-file createObject + getMetaData that + // $integrateComponents performs for wheels.model / wheels.controller / + // wheels.mapper, so that work runs once per app instead of on every model, + // controller, and mapper object materialization (issue #3213). Like the + // schema cache, it lives for the application lifetime and is rebuilt on reload. + application.$wheels.integrationPlans = {}; application.$wheels.helperFileCache = {}; application.$wheels.layoutFileCache = {}; application.$wheels.existingObjectFiles = {}; @@ -536,4 +526,54 @@ component { } return !ListFindNoCase("production,testing,maintenance", arguments.environment); } + + /** + * Resolves the engine name and version from a server-scope-shaped struct. + * Extracted from $init() so the detection ladder is unit-testable. + * + * Order matters — most-specific marker first. RustCFML impersonates Lucee: + * it exposes a server.lucee struct (server.lucee.version reports a Lucee + * 7.x version, server.lucee.versionName is "RustCFML"), so its + * server.coldfusion.productName marker must be checked BEFORE the Lucee + * branch or RustCFML is misclassified as Lucee and its dedicated engine + * adapter becomes dead code. Other probe-verified RustCFML markers: + * server.java.vendor = "RustCFML (no JVM)". + * + * As of RustCFML v0.507.0, reportAsLucee defaults to true: the productName + * marker reports "Lucee" (with Lucee's productVersion) and the real engine + * version moves to server.lucee.version behind a Lucee-major prefix + * ("7.0.519.0" means RustCFML 0.519.0). The one field upstream documents + * as the stable identity marker — their own isRustCFML() BIF keys on it — + * is server.lucee.versionName == "RustCFML", so that branch must also run + * BEFORE the Lucee branch. + */ + public struct function $detectEngine(required struct serverScope) { + local.rv = {}; + if (StructKeyExists(arguments.serverScope, "boxlang")) { + local.rv.serverName = "BoxLang"; + local.rv.serverVersion = arguments.serverScope.boxlang.version; + } else if ( + StructKeyExists(arguments.serverScope, "coldfusion") + && StructKeyExists(arguments.serverScope.coldfusion, "productName") + && arguments.serverScope.coldfusion.productName == "RustCFML" + ) { + local.rv.serverName = "RustCFML"; + local.rv.serverVersion = arguments.serverScope.coldfusion.productVersion; + } else if ( + StructKeyExists(arguments.serverScope, "lucee") + && StructKeyExists(arguments.serverScope.lucee, "versionName") + && arguments.serverScope.lucee.versionName == "RustCFML" + ) { + local.rv.serverName = "RustCFML"; + // Strip the impersonated Lucee major ("7.0.519.0" -> "0.519.0"). + local.rv.serverVersion = ListRest(arguments.serverScope.lucee.version, "."); + } else if (StructKeyExists(arguments.serverScope, "lucee")) { + local.rv.serverName = "Lucee"; + local.rv.serverVersion = arguments.serverScope.lucee.version; + } else { + local.rv.serverName = "Adobe ColdFusion"; + local.rv.serverVersion = arguments.serverScope.coldfusion.productVersion; + } + return local.rv; + } } diff --git a/vendor/wheels/events/onerror/cfmlerror.cfm b/vendor/wheels/events/onerror/cfmlerror.cfm index d6509569ab..961588a704 100644 --- a/vendor/wheels/events/onerror/cfmlerror.cfm +++ b/vendor/wheels/events/onerror/cfmlerror.cfm @@ -65,9 +65,19 @@
+ + + + + +
URL
-
https://#EncodeForHTML(cgi.server_name)##Replace(cgi.script_name, "/#application.wheels.rewriteFile#", "")##EncodeForHTML(request.cgi.path_info)##EncodeForHTML(cgi.path_info)#?#EncodeForHTML(cgi.query_string)#
+
https://#EncodeForHTML(cgi.server_name)##EncodeForHTML(local.errorUrlBase)##EncodeForHTML(request.cgi.path_info)##EncodeForHTML(cgi.path_info)#?#EncodeForHTML(cgi.query_string)#
diff --git a/vendor/wheels/events/onrequestend/debug.cfm b/vendor/wheels/events/onrequestend/debug.cfm index d08fab33e6..1b77f67c7b 100644 --- a/vendor/wheels/events/onrequestend/debug.cfm +++ b/vendor/wheels/events/onrequestend/debug.cfm @@ -7,33 +7,21 @@ OR (StructKeyExists(local.reqHeaders, "X-Fetch") AND local.reqHeaders["X-Fetch"] OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.format))> - + - - - + - - - + - - - - - - - - - - - - - + @@ -87,6 +75,28 @@ OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.fo
+ + +
+ Reload not performed. + + URL-based reload is disabled because reloadPassword is empty (fail-closed since 4.0.4). + Set set(reloadPassword=env('WHEELS_RELOAD_PASSWORD', '')) in config/settings.cfm, + put the value in .env, then reload with ?reload=true&password=... + + A reloadPassword is configured but the request carried no password parameter. + Append &password=<your reloadPassword> to the URL. + + The reload request was refused. Check wheels_security.log for details. + +
+
+
@@ -467,8 +477,11 @@ OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.fo
+
+ - #ReReplace(local.wdbHtml, "(?m)>\s+<", "><", "all")# diff --git a/vendor/wheels/events/testcontext.cfm b/vendor/wheels/events/testcontext.cfm new file mode 100644 index 0000000000..e8dd4976f0 --- /dev/null +++ b/vendor/wheels/events/testcontext.cfm @@ -0,0 +1,75 @@ + + // Included from Application.cfc AFTER config/app.cfm finalizes this.name. + // Issue #3374: bind test-runner / TestClient / browser requests to a + // separate CFML application scope so the live application.wheels is never + // mutated. This file is constructor-context (not a function) — do not use + // the local scope; temp state lives on this.wheels and is deleted after. + // + // Keep the suffix / header CGI key / cookie name in lockstep with + // wheels.events.TestContext — TestRunnerIsolationSpec scans both. + // + // Cannot CreateObject("wheels.events.TestContext") from here: this.mappings + // is not guaranteed to be registered during Application.cfc's constructor. + + if (StructKeyExists(this, "name") && Len(this.name)) { + this.wheels.$testContext = { + suffix = "_wheelsTest", + haystack = "", + match = false + }; + + if ( + Len(this.name) >= Len(this.wheels.$testContext.suffix) + && Right(this.name, Len(this.wheels.$testContext.suffix)) == this.wheels.$testContext.suffix + ) { + this.wheels.$testContext.match = true; + } else { + if (IsDefined("cgi.path_info")) { + this.wheels.$testContext.haystack &= " " & ToString(cgi.path_info); + } + if (IsDefined("cgi.script_name")) { + this.wheels.$testContext.haystack &= " " & ToString(cgi.script_name); + } + if (IsDefined("cgi.query_string")) { + this.wheels.$testContext.haystack &= " " & ToString(cgi.query_string); + } + if (IsDefined("cgi.request_url")) { + this.wheels.$testContext.haystack &= " " & ToString(cgi.request_url); + } + if (IsDefined("cgi.http_url")) { + this.wheels.$testContext.haystack &= " " & ToString(cgi.http_url); + } + + if ( + FindNoCase("/wheels/core/tests", this.wheels.$testContext.haystack) + || FindNoCase("/wheels/app/tests", this.wheels.$testContext.haystack) + ) { + this.wheels.$testContext.match = true; + } + + if ( + !this.wheels.$testContext.match + && IsDefined("cgi.http_x_wheels_test_context") + && Len(ToString(cgi.http_x_wheels_test_context)) + ) { + this.wheels.$testContext.match = true; + } + + if (!this.wheels.$testContext.match) { + try { + if (IsDefined("cookie.WHEELS_TEST_CONTEXT") && Len(ToString(cookie.WHEELS_TEST_CONTEXT))) { + this.wheels.$testContext.match = true; + } + } catch (any e) { + // cookie scope unavailable in this constructor — header/path still apply + } + } + + if (this.wheels.$testContext.match) { + this.name = this.name & this.wheels.$testContext.suffix; + } + } + + StructDelete(this.wheels, "$testContext"); + } + diff --git a/vendor/wheels/global/cache.cfm b/vendor/wheels/global/cache.cfm new file mode 100644 index 0000000000..3219942c7e --- /dev/null +++ b/vendor/wheels/global/cache.cfm @@ -0,0 +1,206 @@ + +/** + * wheels.Global include: cache + * Application-scope cache helpers. + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + // ====================================================================== + // CACHE FUNCTIONS + // ====================================================================== + + /** + * Creates a unique string based on any arguments passed in (used as a key for caching mostly). + */ + public string function $hashedKey() { + local.rv = ""; + + // make all cache keys domain specific (do not use request scope below since it may not always be initialized) + StructInsert(arguments, ListLen(StructKeyList(arguments)) + 1, cgi.http_host, true); + + // we need to make sure we are looping through the passed in arguments in the same order everytime + local.values = []; + local.keyList = ListSort(StructKeyList(arguments), "textnocase", "asc"); + local.keyArray = ListToArray(local.keyList); + local.iEnd = ArrayLen(local.keyArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + ArrayAppend(local.values, arguments[local.keyArray[local.i]]); + } + + if (!ArrayIsEmpty(local.values)) { + // this might fail if a query contains binary data so in those rare cases we fall back on using cfwddx (which is a little bit slower which is why we don't use it all the time) + try { + local.rv = SerializeJSON(local.values); + local.rv = $engineAdapter().normalizeForHash(local.rv); + } catch (any e) { + local.rv = $wddx(input = local.values); + } + } + return Hash(local.rv); + } + + + /** + * Internal function. + * Case-sensitive, constant-time string comparison. Both values are hashed with + * SHA-256 before being compared via MessageDigest.isEqual so the comparison + * neither leaks length information nor exits early on the first differing byte. + * Used by the reload/restart password gate and the environment-switch gate. + */ + public boolean function $secureCompare(required string candidate, required string comparedValue) { + return CreateObject("java", "java.security.MessageDigest").isEqual( + Hash(arguments.candidate, "SHA-256").getBytes("UTF-8"), + Hash(arguments.comparedValue, "SHA-256").getBytes("UTF-8") + ); + } + + + /** + * Internal function. + */ + public any function $timeSpanForCache( + required any cache, + numeric defaultCacheTime = application.wheels.defaultCacheTime, + string cacheDatePart = application.wheels.cacheDatePart + ) { + local.cache = arguments.defaultCacheTime; + if (IsNumeric(arguments.cache)) { + local.cache = arguments.cache; + } + local.listArray = [0, 0, 0, 0]; + local.dateParts = "d,h,n,s"; + local.datePartsArray = ListToArray(local.dateParts); + local.iEnd = ArrayLen(local.datePartsArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + if (arguments.cacheDatePart == local.datePartsArray[local.i]) { + local.listArray[local.i] = local.cache; + } + } + local.rv = CreateTimespan(local.listArray[1], local.listArray[2], local.listArray[3], local.listArray[4]); + return local.rv; + } + + + /** + * Internal function. + */ + public void function $addToCache( + required string key, + required any value, + numeric time = application.wheels.defaultCacheTime, + string category = "main" + ) { + local.currentCount = $cacheCount(); + if ( + application.wheels.cacheCullPercentage > 0 + && application.wheels.cacheLastCulledAt < DateAdd("n", -application.wheels.cacheCullInterval, Now()) + && local.currentCount >= application.wheels.maximumItemsToCache + ) { + // the cache is full so flush out expired items to make more room if possible + // (the maximum applies to the cache as a whole so we cull across all categories, + // otherwise a write to a small category would free nothing and get dropped) + local.deletedItems = 0; + if (application.wheels.cacheCullPercentage < 100) { + local.maxItemsToDelete = Ceiling(local.currentCount * application.wheels.cacheCullPercentage / 100); + } else { + local.maxItemsToDelete = local.currentCount; + } + local.now = Now(); + local.categories = StructKeyArray(application.wheels.cache); + local.iEnd = ArrayLen(local.categories); + for (local.i = 1; local.i <= local.iEnd && local.deletedItems < local.maxItemsToDelete; local.i++) { + local.cacheCategory = local.categories[local.i]; + // snapshot the keys so we never delete from the struct we are iterating over + local.cacheKeys = StructKeyArray(application.wheels.cache[local.cacheCategory]); + local.jEnd = ArrayLen(local.cacheKeys); + for (local.j = 1; local.j <= local.jEnd && local.deletedItems < local.maxItemsToDelete; local.j++) { + local.cacheKey = local.cacheKeys[local.j]; + if ( + StructKeyExists(application.wheels.cache[local.cacheCategory], local.cacheKey) + && local.now > application.wheels.cache[local.cacheCategory][local.cacheKey].expiresAt + ) { + $removeFromCache(key = local.cacheKey, category = local.cacheCategory); + local.deletedItems++; + } + } + } + local.currentCount -= local.deletedItems; + application.wheels.cacheLastCulledAt = Now(); + } + if (local.currentCount < application.wheels.maximumItemsToCache) { + local.cacheItem = {}; + local.cacheItem.expiresAt = DateAdd(application.wheels.cacheDatePart, arguments.time, Now()); + if (IsSimpleValue(arguments.value)) { + local.cacheItem.value = arguments.value; + } else { + local.cacheItem.value = Duplicate(arguments.value); + } + application.wheels.cache[arguments.category][arguments.key] = local.cacheItem; + } + } + + + /** + * Internal function. + */ + public any function $getFromCache(required string key, string category = "main") { + local.rv = false; + try { + if (StructKeyExists(application.wheels.cache[arguments.category], arguments.key)) { + if (Now() > application.wheels.cache[arguments.category][arguments.key].expiresAt) { + $removeFromCache(key = arguments.key, category = arguments.category); + } else { + if (IsSimpleValue(application.wheels.cache[arguments.category][arguments.key].value)) { + local.rv = application.wheels.cache[arguments.category][arguments.key].value; + } else { + local.rv = Duplicate(application.wheels.cache[arguments.category][arguments.key].value); + } + } + } + } catch (any e) { + } + return local.rv; + } + + + /** + * Internal function. + */ + public void function $removeFromCache(required string key, string category = "main") { + StructDelete(application.wheels.cache[arguments.category], arguments.key); + } + + + /** + * Internal function. + */ + public numeric function $cacheCount(string category = "") { + if (Len(arguments.category)) { + local.rv = StructCount(application.wheels.cache[arguments.category]); + } else { + local.rv = 0; + for (local.key in application.wheels.cache) { + local.rv += StructCount(application.wheels.cache[local.key]); + } + } + return local.rv; + } + + + /** + * Internal function. + */ + public void function $clearCache(string category = "") { + if (Len(arguments.category)) { + StructClear(application.wheels.cache[arguments.category]); + } else { + StructClear(application.wheels.cache); + } + } + diff --git a/vendor/wheels/global/cors.cfm b/vendor/wheels/global/cors.cfm new file mode 100644 index 0000000000..42d58c0370 --- /dev/null +++ b/vendor/wheels/global/cors.cfm @@ -0,0 +1,255 @@ + +/** + * wheels.Global include: cors + * CORS header helpers and wildcard domain matching. + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + // ====================================================================== + // CORS FUNCTIONS + // ====================================================================== + + /** + * Wildcard domain match: check if the current cgi.server_name and port satisfies + * the passed in domain string whilst checking for wildcards + * + * @domain string to test against e.g *.foo.com + * @cgi Fake CGI Scope for Testing; will default to normal cgi scope + */ + public boolean function $wildcardDomainMatchCGI(required string domain, struct cgi) { + local.domain = arguments.domain; + local.cgi = StructKeyExists(arguments, "cgi") ? arguments.cgi : $cgiScope(); + + return $wildcardDomainMatch($fullDomainString(local.domain), $fullCgiDomainString(local.cgi)); + } + + + /** + * Wildcard domain match: domain satisfies wildcard + * + * @domain string to test against e.g *.foo.com + * @origin string to test against e.g bar.foo.com + */ + public boolean function $wildcardDomainMatch(required string domain, required string origin) { + local.rv = false; + local.domainfull = $fullDomainString(arguments.domain); + local.originfull = $fullDomainString(arguments.origin); + + // Do we have a wildcard subdomain? + local.hasWildcard = ListContainsNoCase(local.domainfull, "*", '.') && Len(local.domainfull > 1); + + // If not, is it an exact match? + if (!local.hasWildcard && local.domainfull == local.originfull) { + local.rv = true; + } + + // Loop over domain backwards and test the corresponding position in the other array + if (local.hasWildcard) { + local.domainReversed = ListToArray(Reverse(SpanExcluding(Reverse(local.domainfull), "."))); + local.serverNameReversed = ListToArray(Reverse(SpanExcluding(Reverse(local.originfull), "."))); + local.wildcardPassed = true; + // Check each part with corresponding part in other array + for (local.i = 1; i LTE ArrayLen(local.domainReversed); i = i + 1) { + if (local.domainReversed[i] != local.serverNameReversed[i] && local.domainReversed[i] DOES NOT CONTAIN '*') { + local.wildcardPassed = false; + break; + } + } + local.rv = local.wildcardPassed; + } + + return local.rv; + } + + + /** + * Get full domain string from cgi scope: includes protocol and port + * e.g https://www.wheels.dev:443 + * + * @cgi Fake CGI Scope for Testing; will default to normal cgi scope + **/ + public string function $fullCgiDomainString(struct cgi) { + local.cgi = StructKeyExists(arguments, "cgi") ? arguments.cgi : $cgiScope(); + local.server_name = local.cgi.server_name; + local.server_port = local.cgi.server_port; + local.server_protocol = + ( + (StructKeyExists(local.cgi, 'http_x_forwarded_proto') && local.cgi.http_x_forwarded_proto == "https") + || (StructKeyExists(local.cgi, 'server_port_secure') && local.cgi.server_port_secure) + ) + ? "https" : "http"; + return local.server_protocol & '://' & local.server_name & ':' & local.server_port; + } + + + /** + * Get full domain string from a passed in string: includes protocol and port + * e.g https://www.wheels.dev -> https://www.wheels.dev:443 + * e.g www.wheels.dev -> http://www.wheels.dev:80 + * + * @domain The string to look at + **/ + public string function $fullDomainString(required string domain) { + local.domain = arguments.domain; + local.protocol = ListFirst(local.domain, "://"); + local.port = ListLast(local.domain, ":"); + + if (!ListFindNoCase("http,https", local.protocol)) { + if (local.port == 443) { + local.protocol = "https"; + } else { + local.protocol = "http"; + } + local.domain = local.protocol & '://' & local.domain; + } + if (!IsNumeric(local.port)) { + if (local.protocol == 'http') { + local.port = 80; + } else if (local.protocol == 'https') { + local.port = 443; + } + local.domain &= ':' & local.port; + } + return local.domain; + } + + + /** + * Set CORS Headers: only triggered if application.wheels.allowCorsRequests = true + */ + public void function $setCORSHeaders( + string allowOrigin = "", + string allowCredentials = false, + string allowHeaders = "Origin, Content-Type, X-Auth-Token, X-Requested-By, X-Requested-With", + string allowMethods = "GET, POST, PATCH, PUT, DELETE, OPTIONS", + boolean allowMethodsByRoute = false, + string pathInfo = request.cgi.PATH_INFO, + string scriptName = request.cgi.script_name + ) { + local.incomingOrigin = StructKeyExists(request.wheels.httprequestdata.headers, "origin") ? request.wheels.httprequestdata.headers.origin : false; + + // No origins configured — skip all CORS headers (deny all by default) + if (!Len(arguments.allowOrigin)) { + return; + } + + // Either a wildcard, or if a specific domain is set, we need to ensure the incoming request matches it + if (arguments.allowOrigin == "*") { + $header(name = "Access-Control-Allow-Origin", value = arguments.allowOrigin); + } else { + // Passed value may be a list or just a single entry + local.originArr = ListToArray(arguments.allowOrigin); + + // Is this origin in the allowed Array? + for (local.o in local.originArr) { + if ($wildcardDomainMatch(local.o, local.incomingOrigin)) { + $header(name = "Access-Control-Allow-Origin", value = local.incomingOrigin); + $header(name = "Vary", value = "Origin"); + break; + } + } + } + + // Set Origin, Content-Type, X-Auth-Token, X-Requested-By, X-Requested-With Allow Headers + $header(name = "Access-Control-Allow-Headers", value = arguments.allowHeaders); + + // Either Look up Route specific allowed methods, or just use default + if (arguments.allowMethodsByRoute) { + local.permittedMethods = []; + + // NB this is basically duplicate logic: needs refactoring + if (arguments.pathInfo == arguments.scriptName || arguments.pathInfo == "/" || !Len(arguments.pathInfo)) { + local.path = ""; + } else { + local.path = Right(arguments.pathInfo, Len(arguments.pathInfo) - 1); + } + + // Attempt to match the requested route and only display the allowed methods for that route + // Does this info already exist in scope? It seems silly to have to look it up again + for (local.route in application.wheels.routes) { + // Make sure route has been converted to regular expression. + if (!StructKeyExists(local.route, "regex")) { + local.route.regex = application.wheels.mapper.$patternToRegex(local.route.pattern); + } + + // If route matches regular expression, get the methods + if (ReFindNoCase(local.route.regex, local.path)) { + ArrayAppend(local.permittedMethods, local.route.methods); + } + } + if (ArrayLen(local.permittedMethods)) { + $header(name = "Access-Control-Allow-Methods", value = UCase(ArrayToList(local.permittedMethods, ', '))); + } + } else { + $header(name = "Access-Control-Allow-Methods", value = arguments.allowMethods); + } + + // Only add this header if requested (false is an invalid value) + if (arguments.allowCredentials) { + $header(name = "Access-Control-Allow-Credentials", value = true); + } + } + + + /** + * Internal. Returns true when a `wheels.middleware.Cors` instance (or its + * component path) is registered in `application.wheels.middleware`. When it + * is, the dispatch-level Cors middleware is the single source of truth for + * CORS headers and OPTIONS preflight, so the legacy global path + * (`$setCORSHeaders` + the `onRequestStart` OPTIONS abort) must step aside. + * Running both stacks duplicate `Access-Control-Allow-*` headers; a + * duplicate `Access-Control-Allow-Origin` makes browsers reject the + * response per the Fetch spec. Mirrors the detection in + * `Dispatch.$computePreflightCapable()`. (#3114) + */ + public boolean function $corsMiddlewareActive() { + if ( + !StructKeyExists(application, "wheels") + || !StructKeyExists(application.wheels, "middleware") + || !IsArray(application.wheels.middleware) + ) { + return false; + } + for (local.mw in application.wheels.middleware) { + if (IsSimpleValue(local.mw)) { + if (local.mw == "wheels.middleware.Cors") { + return true; + } + } else if (IsObject(local.mw) && IsInstanceOf(local.mw, "wheels.middleware.Cors")) { + return true; + } + } + return false; + } + + + /** + * Internal. Logs a one-time warning when the legacy global CORS path is + * suppressed in favour of a registered `wheels.middleware.Cors` instance, + * so operators notice the redundant `allowCorsRequests=true` setting. (#3114) + */ + public void function $warnGlobalCorsDeferred() { + if (StructKeyExists(application.wheels, "$corsGlobalDeferredWarned")) { + return; + } + cflock(name = "wheels.corsGlobalDeferred.#application.applicationName#", type = "exclusive", timeout = 5) { + if (!StructKeyExists(application.wheels, "$corsGlobalDeferredWarned")) { + application.wheels.$corsGlobalDeferredWarned = true; + cflog( + type = "warning", + file = "wheels", + text = "CORS configuration conflict: both allowCorsRequests=true and a wheels.middleware.Cors " + & "instance are active. The legacy global CORS path is deferring to the middleware to avoid " + & "duplicate Access-Control-Allow-* headers. Disable allowCorsRequests once the Cors middleware " + & "is configured. (##3114)" + ); + } + } + } + diff --git a/vendor/wheels/global/lifecycle.cfm b/vendor/wheels/global/lifecycle.cfm new file mode 100644 index 0000000000..aaa9692e61 --- /dev/null +++ b/vendor/wheels/global/lifecycle.cfm @@ -0,0 +1,465 @@ + +/** + * wheels.Global include: lifecycle + * Error callbacks, interface contracts, global-include reload, protected methods. + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + /** + * Restore the application scope modified by the test runner + */ + public void function $restoreTestRunnerApplicationScope() { + if (StructKeyExists(request, "wheels") && StructKeyExists(request.wheels, "testRunnerApplicationScope")) { + application.wheels = request.wheels.testRunnerApplicationScope; + } + } + + + /** + * Registers a callback function to be invoked when an unhandled error occurs. + * Callbacks receive a single argument: the exception struct. + * Multiple callbacks are invoked in registration order. A failing callback + * is logged and skipped — it will not prevent other callbacks from running. + * Should be called during app initialization, not per-request. + * + * [section: Configuration] + * [category: Error Handling] + * + * @callback A function that accepts an exception struct argument. Must complete quickly — long-running callbacks delay error responses. + */ + public void function registerOnError(required function callback) { + ArrayAppend(application.wheels.onErrorCallbacks, arguments.callback); + } + + + /** + * Fires all registered onError callbacks. Each runs in its own try/catch + * so a broken callback cannot suppress other callbacks or break error rendering. + */ + public void function $fireOnErrorCallbacks(required any exception) { + if ( + StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "onErrorCallbacks") + && IsArray(application.wheels.onErrorCallbacks) + ) { + for (var cb in application.wheels.onErrorCallbacks) { + try { + cb(arguments.exception); + } catch (any e) { + cflog(text = "onError callback failed: #e.message#", type = "error", file = "wheels-errors"); + } + } + } + } + + + /** + * Verifies that mixin-assembled objects satisfy critical interface contracts. + * Runs only in development mode at the end of application bootstrap. + * Checks a subset of essential methods — full verification is done by test specs. + * Logs warnings instead of throwing to avoid blocking app startup. + * Note: the model check is a no-op at startup because models are lazy-loaded + * (application.wheels.models is empty until the first model() call). + * It activates when called later or from tests. + */ + public void function $verifyInterfaceContracts() { + local.issues = []; + + // Check Model interface (requires at least one model to be loaded) + try { + local.modelMethods = [ + "findAll", + "findOne", + "findByKey", + "count", + "exists", + "save", + "valid", + "update", + "delete", + "hasMany", + "belongsTo", + "hasOne", + "validatesPresenceOf" + ]; + if (StructKeyExists(application.wheels, "models") && !StructIsEmpty(application.wheels.models)) { + local.sampleModelName = StructKeyArray(application.wheels.models)[1]; + local.sampleModel = model(local.sampleModelName); + for (local.m in local.modelMethods) { + if (!StructKeyExists(local.sampleModel, local.m)) { + ArrayAppend(local.issues, "Model(#local.sampleModelName#) missing: #local.m#()"); + } + } + } + } catch (any e) { + ArrayAppend(local.issues, "Model contract check failed: #e.message#"); + } + + // Check Controller interface + try { + local.controllerMethods = [ + "renderView", + "renderPartial", + "renderText", + "redirectTo", + "linkTo", + "urlFor", + "startFormTag", + "endFormTag", + "filters", + "verifies" + ]; + local.params = {controller = "wheels", action = "wheels"}; + local.testController = controller(name = "wheels", params = local.params); + for (local.m in local.controllerMethods) { + if (!StructKeyExists(local.testController, local.m)) { + ArrayAppend(local.issues, "Controller missing: #local.m#()"); + } + } + } catch (any e) { + ArrayAppend(local.issues, "Controller contract check failed: #e.message#"); + } + + // Report issues as warnings + if (ArrayLen(local.issues)) { + local.msg = "Interface contract warnings: " & ArrayToList(local.issues, "; "); + cflog(text = local.msg, type = "warning", file = "wheels-errors"); + if (StructKeyExists(application, "wheels") && application.wheels.showDebugInformation) { + request.wheels.interfaceWarnings = local.issues; + } + } + } + + + /** + * Snapshot mtimes of all .cfm files under the app's global include directory. + * + * Used by the bare `?reload=true` path so a developer adding a helper to + * `app/global/*.cfm` does not have to remember the password-gated full reload + * (issue ##2792). + */ + public struct function $snapshotGlobalIncludes(string directory = ExpandPath("/app/global")) { + var snapshot = {}; + if (!DirectoryExists(arguments.directory)) { + return snapshot; + } + var files = DirectoryList(arguments.directory, true, "query", "*.cfm"); + for (var row in files) { + snapshot[row.directory & "/" & row.name] = row.dateLastModified; + } + return snapshot; + } + + + /** + * Compare a prior `$snapshotGlobalIncludes` result against the current + * filesystem state and return true if any tracked .cfm file was added, + * removed, or modified. + * + * Paired with `$snapshotGlobalIncludes` to drive the bare `?reload=true` + * soft-reload path in development (issue ##2792). + */ + public boolean function $globalIncludesChanged( + required struct snapshot, + string directory = ExpandPath("/app/global") + ) { + var current = $snapshotGlobalIncludes(directory = arguments.directory); + for (var key in current) { + if (!StructKeyExists(arguments.snapshot, key)) { + return true; + } + if (DateCompare(arguments.snapshot[key], current[key]) != 0) { + return true; + } + } + for (var key in arguments.snapshot) { + if (!StructKeyExists(current, key)) { + return true; + } + } + return false; + } + + + /** + * Build the comma-list of public framework helper names that get mixed onto + * every controller (from `wheels.Global` + `wheels.controller.*` + + * `wheels.view.*`). Stored on `application.wheels.protectedControllerMethods` + * and consumed by `$callAction()` to reject URL dispatch to framework + * helpers like `env()`, `model()`, `redirectTo()` (issue ##2844). + * + * Derived from `getMetaData().functions` on each source component, mirroring + * what `$integrateComponents` mixes onto a controller. `$`-prefixed names + * are already gated separately and are excluded here. + * + * Adobe CF's `getMetaData().functions` does not enumerate component-body + * includes (#2790), so after the DC7 split (issue ##3241) the public + * helpers that live in `vendor/wheels/global/*.cfm` are also harvested + * from those files. Lucee typically lists the includes in metadata + * already; the extra pass is then a no-op via `ListFindNoCase`. + */ + public string function $buildProtectedControllerMethods() { + var protectedMethods = ""; + var sources = ["wheels.Global"]; + var mixinPaths = ["wheels.controller", "wheels.view"]; + for (var basePath in mixinPaths) { + var folder = ExpandPath("/" & Replace(basePath, ".", "/", "all")); + if (!DirectoryExists(folder)) { + continue; + } + var files = DirectoryList(folder, false, "name", "*.cfc"); + for (var fileName in files) { + ArrayAppend(sources, basePath & "." & Replace(fileName, ".cfc", "", "all")); + } + } + for (var componentPath in sources) { + var meta = GetMetaData(CreateObject("component", componentPath)); + if (!StructKeyExists(meta, "functions")) { + continue; + } + for (var fn in meta.functions) { + if ( + StructKeyExists(fn, "access") && fn.access == "public" + && Left(fn.name, 1) != "$" + && !ListFindNoCase(protectedMethods, fn.name) + ) { + protectedMethods = ListAppend(protectedMethods, fn.name); + } + } + } + var includeNames = $publicFunctionNamesFromGlobalIncludes(); + var includeCount = ArrayLen(includeNames); + for (var includeIndex = 1; includeIndex <= includeCount; includeIndex++) { + if (!ListFindNoCase(protectedMethods, includeNames[includeIndex])) { + protectedMethods = ListAppend(protectedMethods, includeNames[includeIndex]); + } + } + return protectedMethods; + } + + /** + * Public (non-`$`) function names declared in `vendor/wheels/global/*.cfm`. + * Used by `$buildProtectedControllerMethods()` so Adobe CF still rejects + * dispatch to helpers like `env()` / `model()` after those declarations + * moved out of `Global.cfc` itself (issue ##3241, #2790). + */ + public array function $publicFunctionNamesFromGlobalIncludes() { + var publicNames = []; + var allNames = $frameworkGlobalFunctionNames(); + var nameCount = ArrayLen(allNames); + for (var nameIndex = 1; nameIndex <= nameCount; nameIndex++) { + if (Left(allNames[nameIndex], 1) != "$") { + ArrayAppend(publicNames, allNames[nameIndex]); + } + } + return publicNames; + } + + /** + * Every `public function` name declared in `vendor/wheels/global/*.cfm`, + * including `$`-prefixed internals. Cached on `application.wheels` so a + * reload (which rebuilds that struct) re-reads the files. + */ + public array function $frameworkGlobalFunctionNames() { + if (StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "frameworkGlobalFunctionNames")) { + return application.wheels.frameworkGlobalFunctionNames; + } + var names = $readGlobalIncludeFunctionNames(); + if (StructKeyExists(application, "wheels")) { + application.wheels.frameworkGlobalFunctionNames = names; + } + return names; + } + + /** + * Line-scan `vendor/wheels/Global.cfc` plus `vendor/wheels/global/*.cfm` + * for `public ... function name(`. `$include` and siblings stay on the + * CFC itself (include-path contract); everything else lives in the + * includes. Comment-only lines are skipped (Anti-Pattern 14 spirit) + * without a whole-file comment-strip regex — that shape hangs Lucee 7 + * on large sources (see BareCfabortGuardSpec). + */ + public array function $readGlobalIncludeFunctionNames() { + var names = []; + var seen = {}; + var files = []; + var globalCfc = ExpandPath("/wheels/Global.cfc"); + if (FileExists(globalCfc)) { + ArrayAppend(files, globalCfc); + } + var folder = ExpandPath("/wheels/global"); + if (DirectoryExists(folder)) { + var includeFiles = DirectoryList(folder, false, "path", "*.cfm"); + var includeCount = ArrayLen(includeFiles); + for (var includeIndex = 1; includeIndex <= includeCount; includeIndex++) { + ArrayAppend(files, includeFiles[includeIndex]); + } + } + var fileCount = ArrayLen(files); + for (var fileIndex = 1; fileIndex <= fileCount; fileIndex++) { + var content = FileRead(files[fileIndex]); + var fileLines = ListToArray(content, Chr(10), true); + for (var rawLine in fileLines) { + var line = Trim(Replace(rawLine, Chr(13), "", "all")); + if (!Len(line) || Left(line, 2) == "//" || Left(line, 1) == "*" || Left(line, 2) == "/*") { + continue; + } + if (!REFindNoCase("^public\s+", line)) { + continue; + } + var fnPos = FindNoCase("function ", line); + if (!fnPos) { + continue; + } + var after = Trim(Mid(line, fnPos + 9, Len(line))); + var paren = Find("(", after); + if (paren <= 1) { + continue; + } + var name = Trim(Left(after, paren - 1)); + if (!Len(name) || StructKeyExists(seen, name)) { + continue; + } + ArrayAppend(names, name); + seen[name] = true; + } + } + return names; + } + + + /** + * Convert the comma-list returned by `$buildProtectedControllerMethods()` + * into a struct-as-set so `$callAction()` can perform an O(1) + * `StructKeyExists` membership test on the per-request dispatch hot path + * instead of an O(n) `ListFindNoCase` scan over ~100-250 helper names. + * CFML struct keys are case-insensitive by default, preserving the prior + * `ListFindNoCase` semantics (an action named `ENV` is still rejected like + * `env`). Stored on `application.wheels.protectedControllerMethodsLookup` + * alongside the list, which is retained for callers expecting that shape. + */ + public struct function $protectedControllerMethodsLookup(required string methods) { + var lookup = {}; + for (var name in ListToArray(arguments.methods)) { + lookup[name] = true; + } + return lookup; + } + + + /** + * Re-evaluate the given global-includes file into `application.wo`'s + * variables/this scope. Invoked from the bare `?reload=true` soft-reload + * when `$globalIncludesChanged` reports drift (issue ##2792). + * + * `include` inside a method body adds function declarations to the + * method's local scope, not the component's outer scope, so we walk + * local for any user-defined functions and copy them onto variables + * and this so they remain callable on `application.wo` across requests. + */ + public void function $reincludeGlobals(string file = "/app/global/functions.cfm") { + // Evaluate the file in a throwaway instance and bind the functions it + // declares onto variables + this. Done via a separate instance (not a + // bare `include` here) because Adobe CF throws "Routines cannot be + // declared more than once" when a `?reload=true` re-includes a file + // whose UDFs are already bound to application.wo — the prior copy in + // our own scope collides with the re-declaration. A fresh scope per + // call sidesteps that; rebinding here is a plain struct assignment, so + // the updated version replaces the old one on every engine. + var reloaded = new wheels.GlobalIncludeLoader().loadFunctions(arguments.file); + for (var key in reloaded) { + variables[key] = reloaded[key]; + this[key] = reloaded[key]; + } + } + + + /** + * Copy include-injected user functions from `variables` onto `this` so + * they remain enumerable on engines (Adobe CF) where struct-iteration + * only reliably surfaces `this`-scope members. Must stay a function: an + * inline `local.X` iterator in the pseudo-constructor materializes + * `variables.local` and shadows method-local `local` on BoxLang. + * + * The promote-key list is memoized in application scope because this runs + * on EVERY instantiation of every Global-derived component (per model row, + * per controller, per Plugins instance) while its input — the function set + * injected by the `/app/global/functions.cfm` include above — is constant + * for the application lifetime. The memo is keyed per concrete class name + * because whether a subclass's own (e.g. private) methods are already + * registered in `variables` at this point in the pseudo-constructor is + * engine-dependent, so the promotable set is not guaranteed identical + * across subclasses. The gate is the cached key itself, never a separate + * done-flag (##2800 lesson), and the cache lives inside + * `application[$appKey()]`, which `?reload=true` rebuilds as a fresh + * struct — so invalidation is structural. When `application` (or the + * Wheels struct in it) is unavailable — CLI/test bootstrap, early + * application start — we fall back to the full scan without memoizing. + */ + public void function $promoteIncludedGlobalsToThis() { + var promoteCache = ""; + var promoteCacheKey = ""; + if (IsDefined("application")) { + var promoteAppKey = $appKey(); + if (StructKeyExists(application, promoteAppKey) && IsStruct(application[promoteAppKey])) { + var classMetadata = GetMetadata(this); + if (IsStruct(classMetadata) && StructKeyExists(classMetadata, "name") && Len(classMetadata.name)) { + promoteCacheKey = classMetadata.name; + if (!StructKeyExists(application[promoteAppKey], "promotedGlobalKeys")) { + application[promoteAppKey].promotedGlobalKeys = {}; + } + promoteCache = application[promoteAppKey].promotedGlobalKeys; + } + } + } + if (IsStruct(promoteCache) && StructKeyExists(promoteCache, promoteCacheKey)) { + // Memoized path: apply the recorded keys with the same guards the + // fresh scan uses. Keys that vanished from `variables` are skipped + // and keys already on `this` are left alone, so a stale entry can + // never promote something the scan would not have. + var cachedKeys = promoteCache[promoteCacheKey]; + var cachedKeyCount = ArrayLen(cachedKeys); + for (var keyIndex = 1; keyIndex <= cachedKeyCount; keyIndex++) { + var promoteKey = cachedKeys[keyIndex]; + if (StructKeyExists(variables, promoteKey) && !StructKeyExists(this, promoteKey)) { + this[promoteKey] = variables[promoteKey]; + } + } + return; + } + var promotedKeys = $scanAndPromoteIncludedGlobals(); + if (IsStruct(promoteCache)) { + // Concurrent first instantiations may both scan and both assign; + // the value is deterministic per class, so last-write-wins is safe. + promoteCache[promoteCacheKey] = promotedKeys; + } + } + + + /** + * The full `variables` scan behind `$promoteIncludedGlobalsToThis()`: + * promote every variables-scope custom function that is not already on + * `this`, returning the promoted key names. Also serves as the + * non-memoizing fallback when application scope is unavailable. + */ + public array function $scanAndPromoteIncludedGlobals() { + var promotedKeys = []; + for (var promoteKey in variables) { + if (!isCustomFunction(variables[promoteKey])) { + continue; + } + if (structKeyExists(this, promoteKey)) { + continue; + } + this[promoteKey] = variables[promoteKey]; + ArrayAppend(promotedKeys, promoteKey); + } + return promotedKeys; + } + diff --git a/vendor/wheels/global/locking.cfm b/vendor/wheels/global/locking.cfm new file mode 100644 index 0000000000..4a89050b5f --- /dev/null +++ b/vendor/wheels/global/locking.cfm @@ -0,0 +1,60 @@ + +/** + * wheels.Global include: locking + * Double-checked and simple named locks. + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + public any function $doubleCheckedLock( + required string name, + required string condition, + required string execute, + struct conditionArgs = "#StructNew()#", + struct executeArgs = "#StructNew()#", + numeric timeout = 30 + ) { + local.rv = $invoke(method = arguments.condition, invokeArgs = arguments.conditionArgs); + if (IsBoolean(local.rv) AND NOT local.rv) { + lock timeout="#arguments.timeout#" name="#arguments.name#" { + local.rv = $invoke(method = arguments.condition, invokeArgs = arguments.conditionArgs); + if (IsBoolean(local.rv) AND NOT local.rv) { + local.rv = $invoke(method = arguments.execute, invokeArgs = arguments.executeArgs) + } + } + } + return local.rv; + } + + + public any function $simpleLock( + required string name, + required string type, + required string execute, + struct executeArgs = "#StructNew()#", + numeric timeout = 30 + ) { + if (StructKeyExists(arguments, "object")) { + lock name="#arguments.name#" type="#arguments.type#" timeout="#arguments.timeout#" { + local.rv = $invoke( + component = "#arguments.object#", + method = "#arguments.execute#", + argumentCollection = "#arguments.executeArgs#" + ); + } + } else { + arguments.executeArgs.$locked = true; + lock name="#arguments.name#" type="#arguments.type#" timeout="#arguments.timeout#" { + local.rv = $invoke(method = "#arguments.execute#", argumentCollection = "#arguments.executeArgs#"); + } + } + if (StructKeyExists(local, "rv")) { + return local.rv; + } + } + diff --git a/vendor/wheels/global/objects.cfm b/vendor/wheels/global/objects.cfm new file mode 100644 index 0000000000..d3dfcad8c1 --- /dev/null +++ b/vendor/wheels/global/objects.cfm @@ -0,0 +1,498 @@ + +/** + * wheels.Global include: objects + * Model/controller/service lookup, mixin integration plans, object creation. + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + // ====================================================================== + // FACTORY FUNCTIONS + // ====================================================================== + + /** + * Internal function. + */ + public any function $cachedModelClassExists(required string name) { + local.rv = false; + if (StructKeyExists(application.wheels.models, arguments.name)) { + local.rv = application.wheels.models[arguments.name]; + } + return local.rv; + } + + + /** + * Internal function. + * + * Lock-free warm fast-path lookup used by `model()` to bypass + * `$doubleCheckedLock` and its `$invoke` reflective dispatch on cache + * hits. The full `StructKeyExists` chain guards early-bootstrap and + * post-`?reload=true` windows where `application.wheels.models` may + * not yet exist. Returns the cached class on hit, `false` on miss + * (callers fall through to the slow path). + */ + public any function $cachedModelLookup(required string name) { + if ( + StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "models") + && StructKeyExists(application.wheels.models, arguments.name) + ) { + return application.wheels.models[arguments.name]; + } + return false; + } + + + /** + * Internal function. + */ + public any function $cachedControllerClassExists(required string name) { + local.rv = false; + if (StructKeyExists(application.wheels.controllers, arguments.name)) { + local.rv = application.wheels.controllers[arguments.name]; + } + return local.rv; + } + + + /** + * Internal function. + * + * Lock-free warm fast-path lookup used by `controller()`. Same + * shape and bootstrap guards as `$cachedModelLookup`. + */ + public any function $cachedControllerLookup(required string name) { + if ( + StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "controllers") + && StructKeyExists(application.wheels.controllers, arguments.name) + ) { + return application.wheels.controllers[arguments.name]; + } + return false; + } + + + /** + * Internal function. + */ + public any function $createObjectFromRoot(required string path, required string fileName, required string method) { + local.method = arguments.method; + local.component = ListChangeDelims(arguments.path, ".", "/") & "." & ListChangeDelims(arguments.fileName, ".", "/"); + local.argumentCollection = arguments; + if (local.method EQ 'init') { + local.rv = application.wheelsdi.getInstance(name = "#local.component#", initArguments = local.argumentCollection); + } else { + local.instance = application.wheelsdi.getInstance(name = "#local.component#"); + local.rv = Invoke(local.instance, local.method, local.argumentCollection); + } + return local.rv; + } + + + /** + * Internal. Returns a cached "integration plan" for a folder of mixin + * components (e.g. `wheels.model`, `wheels.controller`, `wheels.mapper`): an + * ordered array of `{instance, methods, fullName}` where `instance` is a + * single shared, stateless method-holder component and `methods` is its + * `getMetaData().functions` array. + * + * The directory scan, the per-file `createObject`, and the `getMetaData` + * calls are the expensive — and completely invariant — part of + * `$integrateComponents`: they produce the same result for every object of a + * given type. Before this cache they were re-paid on EVERY model, controller, + * and mapper materialization (every `new()` and every finder row goes through + * `$createInstance` -> `init()` -> `$integrateComponents`), which dominated + * test-suite and request time (issue #3213). Now they run once per path and + * the cheap per-instance work (copying function references into the target's + * `variables`/`this`) is all that remains on the hot path. + * + * The plan is cached in `application.wheels.integrationPlans`, so a reload — + * which rebuilds `application.wheels` — re-scans, the same lifetime contract + * as the schema column cache. The cached method-holder components carry no + * instance state (they are never `init()`'d) and CFML methods bind to the + * object they are invoked on, so sharing their function references across many + * target instances and across concurrent requests is safe. + */ + public array function $componentIntegrationPlan(required string path) { + // During early bootstrap (before application.wheels exists) fall back to + // an uncached build so behavior is identical to the pre-cache code path. + if (!StructKeyExists(application, "wheels")) { + return $buildComponentIntegrationPlan(arguments.path); + } + if (!StructKeyExists(application.wheels, "integrationPlans")) { + lock name="wheels.integrationPlans.#application.applicationName#" type="exclusive" timeout="10" { + if (!StructKeyExists(application.wheels, "integrationPlans")) { + application.wheels.integrationPlans = {}; + } + } + } + if (!StructKeyExists(application.wheels.integrationPlans, arguments.path)) { + local.plan = $buildComponentIntegrationPlan(arguments.path); + lock name="wheels.integrationPlans.#application.applicationName#" type="exclusive" timeout="10" { + application.wheels.integrationPlans[arguments.path] = local.plan; + } + } + return application.wheels.integrationPlans[arguments.path]; + } + + + /** + * Internal. Builds (without caching) the integration plan for a path — the + * directory scan + per-file createObject + getMetaData that + * $componentIntegrationPlan memoizes. The DirectoryList call mirrors the + * original $integrateComponents exactly so file (and therefore override) + * order is unchanged. + */ + public array function $buildComponentIntegrationPlan(required string path) { + local.folderPath = ExpandPath("/#Replace(arguments.path, ".", "/", "all")#"); + local.fileList = DirectoryList(local.folderPath, false, "name", "*.cfc"); + local.rv = []; + for (local.fileName in local.fileList) { + local.componentName = Replace(local.fileName, ".cfc", "", "all"); + local.instance = CreateObject("component", "#arguments.path#.#local.componentName#"); + local.meta = GetMetaData(local.instance); + local.fns = StructKeyExists(local.meta, "functions") ? local.meta.functions : []; + // Pre-resolve the PUBLIC method references once. On the hot path + // (every materialized object) this removes both the per-method + // `.access` filtering and the `instance[name]` scope lookup; only the + // reference assignment into the target remains (issue #3213). Function + // references are late-bound to the object they are invoked on, so the + // shared, cached reference works correctly on every target instance. + local.publicMethods = []; + local.fEnd = ArrayLen(local.fns); + for (local.f = 1; local.f <= local.fEnd; local.f++) { + if (local.fns[local.f].access == "public") { + ArrayAppend(local.publicMethods, { + name = local.fns[local.f].name, + ref = local.instance[local.fns[local.f].name] + }); + } + } + ArrayAppend(local.rv, { + instance = local.instance, + methods = local.fns, + publicMethods = local.publicMethods, + fullName = StructKeyExists(local.meta, "fullName") ? local.meta.fullName : "#arguments.path#.#local.componentName#" + }); + } + return local.rv; + } + + + /** + * Internal. Returns a struct whose KEYS are the function names that a + * registered plugin/package mixin will override for the given component type + * (plus the always-checked "global" type). Empty — the common case, no mixins + * registered — when there are none. Computed from the app-scoped, reload-stable + * application.wheels.mixins so the per-method $willBeOverriddenByMixin function + * call can be replaced by an O(1) struct-membership test on the hot path (#3213). + */ + public struct function $mixinOverrideSet(required string primaryType) { + local.rv = {}; + if ( + !StructKeyExists(application, "wheels") + || !StructKeyExists(application.wheels, "mixins") + || StructIsEmpty(application.wheels.mixins) + ) { + return local.rv; + } + local.types = [arguments.primaryType, "global"]; + for (local.t in local.types) { + if (StructKeyExists(application.wheels.mixins, local.t) && IsStruct(application.wheels.mixins[local.t])) { + StructAppend(local.rv, application.wheels.mixins[local.t], false); + } + } + return local.rv; + } + + + /** + * Internal function. + */ + public void function $debugPoint(required string name) { + if (!StructKeyExists(request.wheels, "execution")) { + request.wheels.execution = {}; + } + local.nameArray = ListToArray(arguments.name); + local.iEnd = ArrayLen(local.nameArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.item = local.nameArray[local.i]; + if (StructKeyExists(request.wheels.execution, local.item)) { + request.wheels.execution[local.item] = GetTickCount() - request.wheels.execution[local.item]; + } else { + request.wheels.execution[local.item] = GetTickCount(); + } + } + } + + + /** + * Internal function. + */ + public any function $fileExistsNoCase(required string absolutePath) { + local.appKey = $appKey(); + // return false by default when the file does not exist in the directory + local.rv = false; + // break up the full path string in the path name only and the file name only + local.path = GetDirectoryFromPath(arguments.absolutePath); + local.file = Replace(arguments.absolutePath, local.path, ""); + // get all existing files in the directory and place them in a list in application scope + local.pathHash = Hash(local.path); + if (!StructKeyExists(application[local.appKey].directoryFiles, local.pathHash)) { + local.dirInfo = $directory(directory = local.path); + application[local.appKey].directoryFiles[local.pathHash] = ValueList(local.dirInfo.name); + } + local.fileList = application[local.appKey].directoryFiles[local.pathHash]; + // loop through the file list and return the file name if exists regardless of case (the == operator is case insensitive) + local.fileArray = ListToArray(local.fileList); + local.iEnd = ArrayLen(local.fileArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.foundFile = local.fileArray[local.i]; + if (local.foundFile == local.file) { + local.rv = local.foundFile; + break; + } + } + return local.rv; + } + + + /** + * Internal function. + */ + public string function $objectFileName(required string name, required string objectPath, required string type) { + // by default we return Model or Controller so that the base component gets loaded + local.rv = capitalize(arguments.type); + + // we are going to memoize the full controller / model path in the + // existing / non-existing structs so we can have controllers / models + // in multiple places (structs give O(1) lookups and atomic writes where + // the comma lists used previously were O(n) scans per materialized object + // and lost entries to unlocked concurrent ListAppend calls) + // + // The name coming into $objectFileName could have dot notation due to + // nested controllers so we need to change delims here on the name + local.fullObjectPath = arguments.objectPath & "/" & ListChangeDelims(arguments.name, '/', '.'); + + if ( + !StructKeyExists(application.wheels.existingObjectFiles, local.fullObjectPath) + && !StructKeyExists(application.wheels.nonExistingObjectFiles, local.fullObjectPath) + ) { + // we have not yet checked if this file exists or not so let's do that + // here (the function below will return the file name with the correct + // case if it exists, false if not) + local.file = $fileExistsNoCase(ExpandPath(local.fullObjectPath) & ".cfc"); + + if (IsBoolean(local.file) && !local.file) { + // no file exists, let's store that if caching is on so we don't have to check it again + if (application.wheels.cacheFileChecking) { + application.wheels.nonExistingObjectFiles[local.fullObjectPath] = false; + } + } else { + // the file exists, let's store the proper case of the file if caching is turned on + local.file = SpanExcluding(local.file, "."); + if (application.wheels.cacheFileChecking) { + application.wheels.existingObjectFiles[local.fullObjectPath] = local.file; + } + } + } + + // if the file exists we return the file name in its proper case + if (StructKeyExists(application.wheels.existingObjectFiles, local.fullObjectPath)) { + local.file = application.wheels.existingObjectFiles[local.fullObjectPath]; + } + + // we've found a file so we'll need to send back the corrected name + // argument as it could have dot notation in it from the mapper + if (StructKeyExists(local, "file") and !IsBoolean(local.file)) { + local.rv = ListSetAt(arguments.name, ListLen(arguments.name, "."), local.file, "."); + } + + return local.rv; + } + + + /** + * Internal function. + */ + public any function $createControllerClass( + required string name, + string controllerPaths = $get("controllerPath"), + string type = "controller" + ) { + // let's allow for multiple controller paths so that plugins can contain controllers + // the last path is the one we will instantiate the base controller on if the controller is not found on any of the paths + local.controllerPathsArray = ListToArray(arguments.controllerPaths); + local.iEnd = ArrayLen(local.controllerPathsArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.controllerPath = local.controllerPathsArray[local.i]; + local.fileName = $objectFileName(name = arguments.name, objectPath = local.controllerPath, type = arguments.type); + if (local.fileName != "Controller" || local.i == ArrayLen(local.controllerPathsArray)) { + application.wheels.controllers[arguments.name] = $createObjectFromRoot( + path = local.controllerPath, + fileName = local.fileName, + method = "$initControllerClass", + name = arguments.name + ); + + local.rv = application.wheels.controllers[arguments.name]; + break; + } + } + return local.rv; + } + + + /** + * Internal function. + */ + public any function $createModelClass( + required string name, + string modelPaths = application.wheels.modelPath, + string type = "model" + ) { + // let's allow for multiple model paths so that plugins can contain models + // the last path is the one we will instantiate the base model on if the model is not found on any of the paths + local.modelPathsArray = ListToArray(arguments.modelPaths); + local.iEnd = ArrayLen(local.modelPathsArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.modelPath = local.modelPathsArray[local.i]; + local.fileName = $objectFileName(name = arguments.name, objectPath = local.modelPath, type = arguments.type); + if (local.fileName != arguments.type || local.i == ArrayLen(local.modelPathsArray)) { + application.wheels.models[arguments.name] = $createObjectFromRoot( + path = local.modelPath, + fileName = local.fileName, + method = "$initModelClass", + name = arguments.name + ); + local.rv = application.wheels.models[arguments.name]; + break; + } + } + return local.rv; + } + + + /** + * Internal function. + */ + public void function $clearModelInitializationCache() { + StructClear(application.wheels.models); + } + + + /** + * Internal function. + */ + public void function $clearControllerInitializationCache() { + StructClear(application.wheels.controllers); + } + + + /** + * Creates and returns a controller object with your own custom name and params. + * Used primarily for testing purposes. + * + * [section: Global Helpers] + * [category: Miscellaneous Functions] + * + * @name Name of the controller to create. + * @params The params struct (combination of form and URL variables). + */ + public any function controller(required string name, struct params = {}) { + // Lock-free warm fast path: skip $doubleCheckedLock + $invoke + // reflective dispatch on cache hits (issue #2897, Stage 1). Returns + // the cached *class*; the params branch below still creates an + // instance when params is non-empty. + local.rv = $cachedControllerLookup(name = arguments.name); + if (IsBoolean(local.rv) && !local.rv) { + local.args = {}; + local.args.name = arguments.name; + local.rv = $doubleCheckedLock( + condition = "$cachedControllerClassExists", + conditionArgs = local.args, + execute = "$createControllerClass", + executeArgs = local.args, + name = "controllerLock#application.applicationName#" + ); + } + if (!StructIsEmpty(arguments.params)) { + local.rv = local.rv.$createControllerObject(arguments.params); + } + return local.rv; + } + + + /** + * Returns a reference to the requested model so that class level methods can be called on it. + * + * [section: Global Helpers] + * [category: Miscellaneous Functions] + * + * @name Name of the model to get a reference to. + */ + public any function model(required string name) { + // Lock-free warm fast path: skip $doubleCheckedLock + $invoke + // reflective dispatch on cache hits (issue #2897, Stage 1). + local.rv = $cachedModelLookup(name = arguments.name); + if (IsBoolean(local.rv) && !local.rv) { + return $doubleCheckedLock( + condition = "$cachedModelClassExists", + conditionArgs = arguments, + execute = "$createModelClass", + executeArgs = arguments, + name = "modelLock#application.applicationName#" + ); + } + return local.rv; + } + + + /** + * Resolve a DI-registered service by name. + * + * [section: Global Helpers] + * [category: Miscellaneous Functions] + * + * @name The registered service name to resolve. + */ + public any function service(required string name) { + if (!IsDefined("application.wheelsdi")) { + Throw( + type = "Wheels.DI.NotInitialized", + message = "The DI container has not been initialized. Ensure your application has started properly." + ); + } + if (!application.wheelsdi.containsInstance(arguments.name)) { + Throw( + type = "Wheels.DI.ServiceNotFound", + message = "No service registered with the name '#arguments.name#'. Check your config/services.cfm registrations." + ); + } + return application.wheelsdi.getInstance(arguments.name); + } + + + /** + * Return a reference to the DI container for direct configuration. + * + * [section: Global Helpers] + * [category: Miscellaneous Functions] + */ + public any function injector() { + if (!IsDefined("application.wheelsdi")) { + Throw( + type = "Wheels.DI.NotInitialized", + message = "The DI container has not been initialized. Ensure your application has started properly." + ); + } + return application.wheelsdi; + } + diff --git a/vendor/wheels/global/pagination.cfm b/vendor/wheels/global/pagination.cfm new file mode 100644 index 0000000000..5d7b01a87f --- /dev/null +++ b/vendor/wheels/global/pagination.cfm @@ -0,0 +1,133 @@ + +/** + * wheels.Global include: pagination + * Pagination store helpers used by finders and views. + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + /** + * Returns a struct with information about the specified paginated query. + * The keys that will be included in the struct are `currentPage`, `totalPages` and `totalRecords`. + * + * [section: Controller] + * [category: Pagination Functions] + * + * @handle The handle given to the query to return pagination information for. + */ + public struct function pagination(string handle = "query") { + local.store = $ensurePaginationStore(); + if ($get("showErrorInformation")) { + if (!StructKeyExists(local.store, arguments.handle)) { + Throw( + type = "Wheels.QueryHandleNotFound", + message = "Wheels couldn't find a query with the handle of `#arguments.handle#`.", + extendedInfo = "Make sure your `findAll` call has the `page` argument specified and matching `handle` argument if specified." + ); + } + } + return local.store[arguments.handle]; + } + + + /** + * Internal function. + * Creates the reserved per-request pagination namespace if it doesn't exist yet. + * + * Pagination handles are caller-supplied names, so storing them directly in `request.wheels` + * put arbitrary user input in the same case-insensitive keyspace as framework-owned request + * state. A handle matching a framework key overwrote it, and — because `pagination()` only + * validates the handle when `showErrorInformation` is on — production reads of an unknown + * handle returned whatever framework struct happened to occupy that key. Both directions are + * closed by confining handles to their own sub-struct (#3339, same fix shape as #3336). + * + * Returns the namespace struct so callers can work through the returned reference instead of + * calling this as a bare statement — Adobe CF 2025's parser rejects a bare dotted call like + * `application.wo.$ensurePaginationStore()` in a script statement position (see the cross-engine + * note in CLAUDE.md). + */ + public struct function $ensurePaginationStore() { + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + } + if (!StructKeyExists(request.wheels, "$pagination")) { + request.wheels["$pagination"] = {}; + } + return request.wheels["$pagination"]; + } + + + /** + * Allows you to set a pagination handle for a custom query so you can perform pagination on it in your view with `paginationLinks`. + * + * [section: Controller] + * [category: Pagination Functions] + * + * @totalRecords Total count of records that should be represented by the paginated links. + * @currentPage Page number that should be represented by the data being fetched and the paginated links. + * @perPage Number of records that should be represented on each page of data. + * @handle Name of handle to reference in `paginationLinks`. + */ + public void function setPagination( + required numeric totalRecords, + numeric currentPage = 1, + numeric perPage = 25, + string handle = "query" + ) { + // NOTE: this should be documented as a controller function but needs to be placed here because the findAll() method calls it. + + // All numeric values must be integers. + arguments.totalRecords = Fix(arguments.totalRecords); + arguments.currentPage = Fix(arguments.currentPage); + arguments.perPage = Fix(arguments.perPage); + + // The totalRecords argument cannot be negative. + if (arguments.totalRecords < 0) { + arguments.totalRecords = 0; + } + + // Default perPage to 25 if it's less then zero. + if (arguments.perPage <= 0) { + arguments.perPage = 25; + } + + // Calculate the total pages the query will have. + arguments.totalPages = Ceiling(arguments.totalRecords / arguments.perPage); + + // The currentPage argument shouldn't be less then 1 or greater then the number of pages. + if (arguments.currentPage >= arguments.totalPages) { + arguments.currentPage = arguments.totalPages; + } + if (arguments.currentPage < 1) { + arguments.currentPage = 1; + } + + // As a convenience for cfquery and cfloop when doing oldschool type pagination. + // Set startrow for cfquery and cfloop. + arguments.startRow = (arguments.currentPage * arguments.perPage) - arguments.perPage + 1; + + // Set maxrows for cfquery. + arguments.maxRows = arguments.perPage; + + // Set endrow for cfloop. + arguments.endRow = (arguments.startRow - 1) + arguments.perPage; + + // The endRow argument shouldn't be greater then the totalRecords or less than startRow. + if (arguments.endRow >= arguments.totalRecords) { + arguments.endRow = arguments.totalRecords; + } + if (arguments.endRow < arguments.startRow) { + arguments.endRow = arguments.startRow; + } + + local.args = Duplicate(arguments); + StructDelete(local.args, "handle"); + local.store = $ensurePaginationStore(); + local.store[arguments.handle] = local.args; + } + diff --git a/vendor/wheels/global/plugins.cfm b/vendor/wheels/global/plugins.cfm new file mode 100644 index 0000000000..18c3a1f3a7 --- /dev/null +++ b/vendor/wheels/global/plugins.cfm @@ -0,0 +1,508 @@ + +/** + * wheels.Global include: plugins + * Plugin/package bootstrap, deprecation, version checks. + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + /** + * Returns a list of the names of all installed plugins. + * + * [section: Global Helpers] + * [category: Miscellaneous Functions] + */ + public string function pluginNames() { + return StructKeyList(application.wheels.plugins); + } + + + /** + * Internal function. Returns the application-cached Plugins instance so the + * request-lifecycle call sites (onDIcomplete on controllers, models and the + * dispatcher, plus $runOnRequestStart) don't construct a throwaway + * wheels.Plugins — and its wheels.Global parent pseudo-constructor — per + * request / per materialized model row (issue 2897, Stage 3). Falls back to + * a fresh instance during bootstrap windows where the cache has not been + * populated yet, or where the application scope is undefined (CLI / test + * bootstrap). Sharing one instance is safe because $initializeMixins keeps + * its scratch state local-scoped. + */ + public any function $pluginObj() { + if (IsDefined("application")) { + local.appKey = StructKeyExists(application, "$wheels") ? "$wheels" : "wheels"; + if (StructKeyExists(application, local.appKey) && StructKeyExists(application[local.appKey], "PluginObj")) { + return application[local.appKey].PluginObj; + } + } + return CreateObject("component", "wheels.Plugins"); + } + + + /** + * Internal function. Records a deprecation warning through a single shared + * policy: the first call for a given feature logs a warning to the standard + * wheels log and registers the warning in + * application[appKey].deprecationWarnings so running apps can surface it + * (debug panel, tooling). Subsequent calls for the same feature are no-ops, + * making the helper safe to call from per-request code paths. The dedup + * check, registration, and log write run atomically under an exclusive + * lock so concurrent first callers (e.g. parallel first requests hitting a + * deprecated per-request helper) register and log exactly once. If the + * Wheels application struct does not exist yet, the helper is a silent + * no-op: with no registry to dedup against, logging would fire on every + * call, and all framework callers run after the struct is established. + * + * @feature Stable identifier for the deprecated feature (e.g. "plugins-directory", "paginationLinks"). + * @message Human-readable message: what is deprecated, what replaces it, and when it goes away. + * @docUrl Optional URL of the migration guide, appended to the logged message. + */ + public void function $deprecated(required string feature, required string message, string docUrl = "") { + try { + local.appKey = $appKey(); + if (StructKeyExists(application, local.appKey)) { + // One app-wide lock (rather than per-feature) also serializes the lazy + // creation of the registry array itself; contention is a non-issue at + // once-per-feature-per-application frequency. + lock name="wheels_deprecated_registry" type="exclusive" timeout="5" { + if (!StructKeyExists(application[local.appKey], "deprecationWarnings")) { + application[local.appKey].deprecationWarnings = []; + } + for (local.existing in application[local.appKey].deprecationWarnings) { + if (local.existing.feature == arguments.feature) { + return; + } + } + ArrayAppend(application[local.appKey].deprecationWarnings, { + feature = arguments.feature, + message = arguments.message, + url = arguments.docUrl + }); + // Log if-and-only-if the registration above just succeeded; the + // registry is what enforces the warn-once policy for the log too. + try { + local.text = "[Wheels] Deprecation: " & arguments.message; + if (Len(arguments.docUrl)) { + local.text &= " See: " & arguments.docUrl; + } + WriteLog(type = "warning", text = local.text, file = "wheels"); + } catch (any e) { + // Logging is best-effort; the registry entry above already records the warning. + } + } + } + } catch (any e) { + // Best-effort by design (including lock timeouts); never let a + // deprecation notice break the caller. + } + } + + + // Returns the running framework version. Delegates to BuildInfo.cfc, which + // is the authoritative version source. The historical box.json-reading + // implementation (with monorepo / wheels-base-template fallback chain) + // was retired when BuildInfo became the source of truth — see the BuildInfo + // header for migration context. Kept as a thin wrapper because callers + // upstream of onapplicationstart (e.g. PackageLoader, Plugins) and tests + // reference $readFrameworkVersion by name. + public string function $readFrameworkVersion() { + return new wheels.BuildInfo().version(); + } + + + public string function $checkMinimumVersion(required string engine, required string version) { + local.rv = ""; + local.version = Replace(arguments.version, ".", ",", "all"); + local.major = Val(ListGetAt(local.version, 1)); + local.minor = 0; + local.patch = 0; + local.build = 0; + if (ListLen(local.version) > 1) { + local.minor = Val(ListGetAt(local.version, 2)); + } + if (ListLen(local.version) > 2) { + local.patch = Val(ListGetAt(local.version, 3)); + } + if (ListLen(local.version) > 3) { + local.build = Val(ListGetAt(local.version, 4)); + } + if (arguments.engine == "BoxLang") { + local.minimumMajor = "1"; + local.minimumMinor = "0"; + local.minimumPatch = "0"; + local.maximumMajor = "1"; + local.maximumMinor = "15"; + local.maximumPatch = "999"; + + // Check minimum version + if ( + local.major < local.minimumMajor + || (local.major == local.minimumMajor && local.minor < local.minimumMinor) + || (local.major == local.minimumMajor && local.minor == local.minimumMinor && local.patch < local.minimumPatch) + ) { + local.rv = "The Wheels framework requires BoxLang version #local.minimumMajor#.#local.minimumMinor#.#local.minimumPatch# or higher. You are currently running version #arguments.version#."; + } + + // Check maximum version (optional - for major version compatibility) + if ( + local.major > local.maximumMajor + || (local.major == local.maximumMajor && local.minor > local.maximumMinor) + || (local.major == local.maximumMajor && local.minor == local.maximumMinor && local.patch > local.maximumPatch) + ) { + local.rv = "The Wheels framework has been tested up to BoxLang version #local.maximumMajor#.#local.maximumMinor#.#local.maximumPatch#. You are currently running version #arguments.version#. Please check for framework updates or compatibility issues."; + } + } else if (arguments.engine == "Lucee") { + local.minimumMajor = "5"; + local.minimumMinor = "3"; + local.minimumPatch = "2"; + local.minimumBuild = "77"; + // per-major-release floor consumed by the `StructKeyExists(local, local.major)` + // check below (keyed by the running engine's major version number) + local.5 = {minimumMinor = 2, minimumPatch = 1, minimumBuild = 9}; + } else if (arguments.engine == "Adobe ColdFusion") { + // Adobe ColdFusion 2018 is the oldest supported Adobe engine + // (CF 11 / 2016 are end-of-life and no longer supported) + local.minimumMajor = "2018"; + local.minimumMinor = "0"; + local.minimumPatch = "0"; + local.minimumBuild = ""; + } else if (arguments.engine == "RustCFML") { + // RustCFML is a pre-1.0, rapidly evolving experimental engine that + // Wheels supports on a best-effort basis. Accept any version (leave + // local.rv = "") rather than enforcing a minimum; per-version + // divergences are tracked via the RustCFMLAdapter capabilities. + local.rv = ""; + } else { + local.rv = false; + } + if (StructKeyExists(local, "minimumMajor")) { + if ( + local.major < local.minimumMajor + || (local.major == local.minimumMajor && local.minor < local.minimumMinor) + || (local.major == local.minimumMajor && local.minor == local.minimumMinor && local.patch < local.minimumPatch) + || ( + local.major == local.minimumMajor + && local.minor == local.minimumMinor + && local.patch == local.minimumPatch + && Len(local.minimumBuild) + && local.build < local.minimumBuild + ) + ) { + local.rv = local.minimumMajor & "." & local.minimumMinor & "." & local.minimumPatch; + if (Len(local.minimumBuild)) { + local.rv &= "." & local.minimumBuild; + } + } + if (StructKeyExists(local, local.major)) { + // special requirements for having a specific minor or patch version within a major release exists + if ( + local.minor < local[local.major].minimumMinor + || (local.minor == local[local.major].minimumMinor && local.patch < local[local.major].minimumPatch) + ) { + local.rv = local.major & "." & local[local.major].minimumMinor & "." & local[local.major].minimumPatch; + } + } + } + return local.rv; + } + + + /** + * Internal function. Normalizes mixin-collision records to a single + * shared shape: {target, method, firstProvider, secondProvider, + * acknowledged, source}. Plugins.cfc emits legacy-shaped records + * ({existingPlugin, overridingPlugin}) while PackageLoader.cfc and the + * cross-system merge in $loadPackages emit the shared shape directly; + * all of them end up in the same application.wheels.mixinCollisions + * array, which /wheels/plugins and the development debug footer consume + * unconditionally — a mixed-shape array crashes those surfaces with a + * "key doesn't exist" error. + */ + public array function $normalizeMixinCollisions(required array collisions) { + local.rv = []; + for (local.c in arguments.collisions) { + ArrayAppend(local.rv, { + target = local.c.target, + method = local.c.method, + firstProvider = StructKeyExists(local.c, "firstProvider") ? local.c.firstProvider : local.c.existingPlugin, + secondProvider = StructKeyExists(local.c, "secondProvider") ? local.c.secondProvider : local.c.overridingPlugin, + acknowledged = StructKeyExists(local.c, "acknowledged") ? local.c.acknowledged : false, + source = StructKeyExists(local.c, "source") ? local.c.source : "plugin" + }); + } + return local.rv; + } + + + /** + * Internal function. + */ + public void function $loadPlugins() { + local.appKey = $appKey(); + local.pluginPath = application[local.appKey].webPath & application[local.appKey].pluginPath; + application[local.appKey].PluginObj = $createObjectFromRoot( + path = "wheels", + fileName = "Plugins", + method = "$init", + pluginPath = local.pluginPath, + deletePluginDirectories = application[local.appKey].deletePluginDirectories, + overwritePlugins = application[local.appKey].overwritePlugins, + loadIncompatiblePlugins = application[local.appKey].loadIncompatiblePlugins, + wheelsEnvironment = application[local.appKey].environment, + wheelsVersion = application[local.appKey].version + ); + application[local.appKey].plugins = application[local.appKey].PluginObj.getPlugins(); + application[local.appKey].pluginMeta = application[local.appKey].PluginObj.getPluginMeta(); + application[local.appKey].incompatiblePlugins = application[local.appKey].PluginObj.getIncompatiblePlugins(); + application[local.appKey].dependantPlugins = application[local.appKey].PluginObj.getDependantPlugins(); + application[local.appKey].versionMismatchPlugins = application[local.appKey].PluginObj.getVersionMismatchPlugins(); + // Plugins.cfc emits legacy-shaped collision records ({existingPlugin, + // overridingPlugin}); normalize them to the shared shape at the merge + // point so package- and cross-system records (which already use + // {firstProvider, secondProvider}) can live in the same array without + // crashing the consumers (/wheels/plugins and the debug footer). + application[local.appKey].mixinCollisions = $normalizeMixinCollisions( + application[local.appKey].PluginObj.getMixinCollisions() + ); + application[local.appKey].mixins = application[local.appKey].PluginObj.getMixins(); + application[local.appKey].pluginMiddleware = application[local.appKey].PluginObj.getPluginMiddleware(); + // Invoke register(container) on ServiceProviderInterface plugins before activation + if (IsDefined("application.wheelsdi") && ArrayLen(application[local.appKey].PluginObj.getServiceProviders())) { + application[local.appKey].PluginObj.$invokeServiceProviderRegister(application.wheelsdi); + // Boot after all register() calls complete — plugins can now resolve services + application[local.appKey].PluginObj.$invokeServiceProviderBoot(application[local.appKey]); + } + // Invoke onPluginActivate lifecycle hook on all plugins now that everything is in the application scope + application[local.appKey].PluginObj.$invokeOnPluginActivate(); + } + + + /** + * Discovers and loads packages from the vendor/ directory via PackageLoader. + * Merges package mixins into the existing application mixins struct so they + * participate in the standard $initializeMixins injection pipeline. + */ + public void function $loadPackages() { + local.appKey = $appKey(); + local.vendorPath = ExpandPath(application[local.appKey].packagePath); + + application[local.appKey].PackageLoaderObj = $createObjectFromRoot( + path = "wheels", + fileName = "PackageLoader", + method = "init", + vendorPath = local.vendorPath, + wheelsVersion = application[local.appKey].version, + wheelsEnvironment = application[local.appKey].environment + ); + + application[local.appKey].packages = application[local.appKey].PackageLoaderObj.getPackages(); + application[local.appKey].packageMeta = application[local.appKey].PackageLoaderObj.getPackageMeta(); + application[local.appKey].failedPackages = application[local.appKey].PackageLoaderObj.getFailedPackages(); + + // Ensure mixinCollisions exists (unset when no plugins loaded before packages) + if (!StructKeyExists(application[local.appKey], "mixinCollisions")) { + application[local.appKey].mixinCollisions = []; + } + + // Carry forward any collisions the PackageLoader detected internally + for (local.c in application[local.appKey].PackageLoaderObj.getMixinCollisions()) { + ArrayAppend(application[local.appKey].mixinCollisions, local.c); + } + + // Merge package mixins into the existing mixins struct (plugins loaded first, packages overlay). + // Detect cross-system collisions — a package method that shadows a plugin method on the + // same target — before StructAppend silently overwrites. + local.pkgMixins = application[local.appKey].PackageLoaderObj.getMixins(); + local.pluginProviders = StructKeyExists(application[local.appKey], "PluginObj") + ? application[local.appKey].PluginObj.getMethodProviders() + : {}; + local.pkgProviders = application[local.appKey].PackageLoaderObj.getMethodProviders(); + for (local.target in local.pkgMixins) { + if (!StructKeyExists(application[local.appKey].mixins, local.target)) { + application[local.appKey].mixins[local.target] = {}; + } + for (local.methodName in local.pkgMixins[local.target]) { + if (StructKeyExists(application[local.appKey].mixins[local.target], local.methodName)) { + // Only treat this as a cross-system collision when the existing entry + // came from a known plugin. Without an attributable plugin provider + // the prior entry could be framework-internal or pre-seeded, and a + // "migrate the plugin" recommendation would be misleading. + local.pluginAttributable = StructKeyExists(local.pluginProviders, local.target) + && StructKeyExists(local.pluginProviders[local.target], local.methodName); + if (!local.pluginAttributable) { + continue; + } + local.pluginName = local.pluginProviders[local.target][local.methodName]; + local.pkgName = StructKeyExists(local.pkgProviders, local.target) + && StructKeyExists(local.pkgProviders[local.target], local.methodName) + ? local.pkgProviders[local.target][local.methodName] + : "(unknown package)"; + ArrayAppend(application[local.appKey].mixinCollisions, { + target = local.target, + method = local.methodName, + firstProvider = local.pluginName, + secondProvider = local.pkgName, + acknowledged = false, + source = "cross" + }); + WriteLog( + type = "warning", + text = "[Wheels] Cross-system mixin collision: method '#local.methodName#' on target '#local.target#' provided by plugin '#local.pluginName#' is being overwritten by package '#local.pkgName#'. Migrate the plugin to a package or remove the duplicate to resolve." + ); + } + } + StructAppend(application[local.appKey].mixins[local.target], local.pkgMixins[local.target]); + } + + // Merge package middleware into pluginMiddleware (shared pipeline) + local.pkgMiddleware = application[local.appKey].PackageLoaderObj.getPackageMiddleware(); + for (local.mw in local.pkgMiddleware) { + ArrayAppend(application[local.appKey].pluginMiddleware, local.mw); + } + + // Invoke ServiceProvider register/boot if DI container exists. The + // gate asks the loader (not just getServiceProviders()) because lazy + // service-hinted packages aren't instantiated yet at this point — + // $invokeServiceProviderRegister pulls them into the lifecycle, so a + // vendor tree containing only lazy service packages still needs the + // lifecycle invoked. + if (IsDefined("application.wheelsdi") && application[local.appKey].PackageLoaderObj.$hasServiceProviderWork()) { + application[local.appKey].PackageLoaderObj.$invokeServiceProviderRegister(application.wheelsdi); + application[local.appKey].PackageLoaderObj.$invokeServiceProviderBoot(application[local.appKey]); + // Re-sync the application-scope copy so register()/boot() failure + // records are visible there too. Adobe CF copies arrays by value on + // assignment, so the copy taken above (pre-invoke) never receives + // lifecycle-phase entries on those engines — only Lucee/BoxLang share + // the reference. Re-assigning is harmless on Lucee/BoxLang (same + // reference) and required on Adobe (fresh copy including new entries). + application[local.appKey].failedPackages = application[local.appKey].PackageLoaderObj.getFailedPackages(); + } + + // Surface an aggregate summary when any packages failed to load. Without + // this, PackageLoader records each failure in variables.failedPackages and + // emits per-package WriteLog calls — but a developer who hits a downstream + // "No matching function [BASECOATINCLUDES]" error has no obvious place to + // look. Logging a single high-visibility WARN to wheels.log + a stronger + // one to wheels-errors.log gives a clear breadcrumb back to the root cause. + // Runs after the ServiceProvider lifecycle invoke so register()/boot() + // failures appear in the same summary as load-phase failures. + if (ArrayLen(application[local.appKey].failedPackages)) { + local.failNames = ""; + local.failDetail = ""; + for (local.fp in application[local.appKey].failedPackages) { + local.failNames = ListAppend(local.failNames, local.fp.name); + local.failDetail &= " - " & local.fp.name & ": " & local.fp.error & Chr(10); + } + try { + writeLog( + file = "wheels", + type = "warning", + text = "Wheels: " & ArrayLen(application[local.appKey].failedPackages) + & " package(s) failed to load: " & local.failNames + & ". Helpers / services these packages provide will be unavailable —" + & " calling code typically surfaces this as 'No matching function [...]" + & "' or 'No service registered with the name [...]'." + & " Per-package detail in wheels-errors.log." + ); + writeLog( + file = "wheels-errors", + type = "error", + text = "Wheels: " & ArrayLen(application[local.appKey].failedPackages) + & " package(s) failed to load:" & Chr(10) & local.failDetail + ); + } catch (any e) { + // Logging is best-effort during application start. + } + } + } + + + /** + * NB: url rewriting files need to be removed from here. + */ + public string function $buildReleaseZip( + string version = application.wheels.version, + string directory = ExpandPath("/") + ) { + local.name = "wheels-" & LCase(Replace(arguments.version, " ", "-", "all")); + local.name = Replace(local.name, "alpha-", "alpha."); + local.name = Replace(local.name, "beta-", "beta."); + local.name = Replace(local.name, "rc-", "rc."); + local.path = arguments.directory & local.name & ".zip"; + + // directories & files to add to the zip + local.include = [ + "/config", + "/app/controllers", + "/app/events", + "/app/lib", + "/app/migrator", + "files", + "/app/global", + "images", + "javascripts", + "miscellaneous", + "/app/models", + "/plugins", + "stylesheets", + "/tests", + "/app/views", + "/vendor/wheels", + "Application.cfc", + "../wheels.json", + "../box.json", + "index.cfm" + ]; + + // directories & files to be removed + local.exclude = ["/wheels/rocketunit_tests", "/wheels/public/build.cfm", "/wheels/tests"]; + + // filter out these bad boys + local.filter = "*.settings, *.classpath, *.project, *.DS_Store"; + + // The change log and license are copied to the wheels directory only for the build. + // FileCopy(ExpandPath("CHANGELOG.md"), ExpandPath("/wheels/CHANGELOG.md")); + // FileCopy(ExpandPath("LICENSE"), ExpandPath("/wheels/LICENSE")); + + // Entries starting with "/" or ".." → treat as project-root paths (keep original folder structure) + // Entries without "/" → treat as webroot (/public) paths + for (local.i in local.include) { + if (FileExists(ExpandPath(local.i))) { + if (Left(local.i, 1) neq "/" && Left(local.i, 2) neq "..") { + $zip(file = local.path, source = ExpandPath(local.i), prefix = "/public"); + } else { + $zip(file = local.path, source = ExpandPath(local.i)); + } + } else if (DirectoryExists(ExpandPath(local.i))) { + if (Left(local.i, 1) neq "/" && Left(local.i, 2) neq "..") { + $zip(file = local.path, source = ExpandPath(local.i), prefix = "/public/#local.i#"); + } else { + $zip(file = local.path, source = ExpandPath(local.i), prefix = local.i); + } + } else { + Throw( + type = "Wheels.Build", + message = "#ExpandPath(local.i)# not found", + detail = "All paths specified in local.include must exist" + ); + } + }; + + for (local.i in local.exclude) { + $zip(file = local.path, action = "delete", entrypath = local.i); + }; + $zip(file = local.path, action = "delete", filter = local.filter, recurse = true); + + // Clean up. + /* Might not need this because the wheels folder is outside the app now */ + // FileDelete(ExpandPath("/wheels/CHANGELOG.md")); + // FileDelete(ExpandPath("/wheels/LICENSE")); + + return local.path; + } + diff --git a/vendor/wheels/global/request.cfm b/vendor/wheels/global/request.cfm new file mode 100644 index 0000000000..cc292cd066 --- /dev/null +++ b/vendor/wheels/global/request.cfm @@ -0,0 +1,587 @@ + +/** + * wheels.Global include: request + * Request scope, CGI, paths, abort/404, engine adapter, processRequest. + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + // ====================================================================== + // REQUEST FUNCTIONS + // ====================================================================== + + /** + * Internal function. + */ + public void function $initializeRequestScope() { + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + request.wheels.params = {}; + request.wheels.cache = {}; + request.wheels.urlForCache = {}; + request.wheels.tickCountId = GetTickCount(); + + // Copy HTTP request data (contains content, headers, method and protocol). + // This makes internal testing easier since we can overwrite it temporarily from the test suite. + request.wheels.httpRequestData = GetHTTPRequestData(); + + // Create a structure to track the transaction status for all adapters. + request.wheels.transactions = {}; + } + } + + + /** + * Get the status code (e.g. 200, 404 etc) of the response we're about to send. + */ + public string function $statusCode() { + if ($hasEngineAdapter()) { + return $engineAdapter().getStatusCode(); + } + // Fallback when adapter not yet initialized (e.g. error during startup) + if (StructKeyExists(server, "lucee") || StructKeyExists(server, "boxlang")) { + return GetPageContext().getResponse().getStatus(); + } + return GetPageContext() + .getFusionContext() + .getResponse() + .getStatus(); + } + + + /** + * Gets the value of the content type header (blank string if it doesn't exist) of the response we're about to send. + */ + public string function $contentType() { + if ($hasEngineAdapter()) { + return $engineAdapter().getContentType(); + } + // Fallback when adapter not yet initialized + local.rv = ""; + if (StructKeyExists(server, "lucee")) { + local.response = GetPageContext().getResponse(); + } else if (StructKeyExists(server, "boxlang")) { + local.response = GetPageContext(); + } else { + local.response = GetPageContext().getFusionContext().getResponse(); + } + try { + if (StructKeyExists(server, "boxlang")) { + local.header = local.response.getRequest().getHeader("Content-Type"); + } else { + local.header = local.response.containsHeader("Content-Type") ? local.response.getHeader("Content-Type") : Javacast( + "null", + "" + ); + } + if (!IsNull(local.header)) { + local.rv = local.header; + } + } catch (any e) { + } + return local.rv; + } + + + /** + * This copies all the variables Wheels needs from the CGI scope to the request scope. + */ + public struct function $cgiScope( + string keys = "request_method,http_x_requested_with,http_referer,server_name,path_info,script_name,query_string,remote_addr,server_port,server_port_secure,server_protocol,http_host,http_accept,content_type,http_x_rewrite_url,http_x_original_url,request_uri,redirect_url,http_x_forwarded_for,http_x_forwarded_proto", + struct scope = cgi + ) { + local.rv = {}; + local.keyArray = ListToArray(arguments.keys); + local.iEnd = ArrayLen(local.keyArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.item = local.keyArray[local.i]; + local.rv[local.item] = arguments.scope[local.item]; + } + + // fix path_info if it contains any characters that are not ascii (see issue 138) + if (StructKeyExists(arguments.scope, "unencoded_url") && Len(arguments.scope.unencoded_url)) { + local.requestUrl = UrlDecode(arguments.scope.unencoded_url); + } else if (IsSimpleValue(GetPageContext().getRequest().getRequestURL())) { + // remove protocol, domain, port etc from the url + local.requestUrl = "/" & ListDeleteAt( + ListDeleteAt(UrlDecode(GetPageContext().getRequest().getRequestURL()), 1, "/"), + 1, + "/" + ); + } + if (StructKeyExists(local, "requestUrl") && ReFind("[^\x00-\x80]", local.requestUrl)) { + // strip out the script_name and query_string leaving us with only the part of the string that should go in path_info + local.rv.path_info = Replace( + Replace(local.requestUrl, arguments.scope.script_name, ""), + "?" & UrlDecode(arguments.scope.query_string), + "" + ); + } + + // fixes IIS issue that returns a blank cgi.path_info + if (!Len(local.rv.path_info) && Right(local.rv.script_name, 10) == "/index.cfm") { + if (Len(local.rv.http_x_rewrite_url)) { + // IIS6 1/ IIRF (Ionics Isapi Rewrite Filter) + local.rv.path_info = ListFirst(local.rv.http_x_rewrite_url, "?"); + } else if (Len(local.rv.http_x_original_url)) { + // IIS7 rewrite default + local.rv.path_info = ListFirst(local.rv.http_x_original_url, "?"); + } else if (Len(local.rv.request_uri)) { + // Apache default + local.rv.path_info = ListFirst(local.rv.request_uri, "?"); + } else if (Len(local.rv.redirect_url)) { + // Apache fallback + local.rv.path_info = ListFirst(local.rv.redirect_url, "?"); + } + + // finally lets remove the index.cfm because some of the custom cgi variables don't bring it back + // like this it means at the root we are working with / instead of /index.cfm + if (Len(local.rv.path_info) >= 10 && Right(local.rv.path_info, 10) == "/index.cfm") { + // this will remove the index.cfm and the trailing slash + local.rv.path_info = Replace(local.rv.path_info, "/index.cfm", ""); + if (!Len(local.rv.path_info)) { + // add back the forward slash if path_info was "/index.cfm" + local.rv.path_info = "/"; + } + } + } + + // some web servers incorrectly place index.cfm in the path_info but since that should never be there we can safely remove it + if (Find("index.cfm/", local.rv.path_info)) { + Replace(local.rv.path_info, "index.cfm/", ""); + } + return local.rv; + } + + + /** + * Internal function. Returns whether the application has opted into trusting `X-Forwarded-*` + * headers via `set(trustProxyHeaders=true)`. Guarded so it is safe to call on a cold start + * before `application.wheels` exists (resolves to `false`, i.e. do not trust). + */ + public boolean function $trustProxyHeaders() { + return StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "trustProxyHeaders") + && IsBoolean(application.wheels.trustProxyHeaders) + && application.wheels.trustProxyHeaders; + } + + + /** + * Internal function. Resolves the trusted client IP for security decisions. + * Returns `REMOTE_ADDR` (the socket address) unless `trustProxyHeaders` is enabled and + * `X-Forwarded-For` is non-empty, in which case the rightmost hop is used — that is the entry + * appended by the trusted proxy nearest the app; earlier entries are client-supplied and + * spoofable. For this to be safe the proxy must overwrite — never append to — the incoming + * header. + */ + public string function $trustedClientIp(string remoteAddr, string forwardedFor) { + if (!StructKeyExists(arguments, "remoteAddr")) { + arguments.remoteAddr = cgi.remote_addr; + } + if (!StructKeyExists(arguments, "forwardedFor")) { + arguments.forwardedFor = cgi.http_x_forwarded_for; + } + local.rv = Trim(arguments.remoteAddr); + if ($trustProxyHeaders() && Len(Trim(arguments.forwardedFor))) { + local.rv = Trim(ListLast(arguments.forwardedFor)); + } + return local.rv; + } + + + /** + * Internal function. Returns whether the current client is exempt from maintenance mode. + * The exception list comes from config only (`set(ipExceptions="...")`). A list containing + * letters is matched against the user agent (legacy behavior preserved verbatim); otherwise + * it is matched against the trusted client IP. + */ + public boolean function $maintenanceModeExempt( + required string exceptions, + required string userAgent, + required string clientIp + ) { + if (!Len(arguments.exceptions)) { + return false; + } + if (ReFindNoCase("[a-z]", arguments.exceptions)) { + return ListFindNoCase(arguments.exceptions, arguments.userAgent) > 0; + } + return ListFind(arguments.exceptions, arguments.clientIp) > 0; + } + + + /** + * Internal function. Derives `webPath`, `rootPath`, `rootcomponentPath`, + * and `wheelsComponentPath` from either an explicit URL `subpath` + * (issue #2968 — subfolder installs where `cgi.script_name` does not + * reflect the public mount) or, when no subpath is given, the existing + * `cgi.script_name` derivation. Returning a struct keeps the helper + * pure so it can be unit-tested in isolation. + */ + public struct function $resolveFrameworkPaths(required string scriptName, string subpath = "") { + local.rv = {}; + local.normalized = Trim(arguments.subpath); + if (Len(local.normalized) && Left(local.normalized, 1) != "/") { + local.normalized = "/" & local.normalized; + } + // Strip trailing slash(es) without falling through to Left(str, 0), + // which crashes Lucee 7 (see CLAUDE.md § "Cross-Engine Invariants"). + while (Len(local.normalized) > 1 && Right(local.normalized, 1) == "/") { + local.normalized = Left(local.normalized, Len(local.normalized) - 1); + } + if (Len(local.normalized)) { + local.rv.webPath = local.normalized == "/" ? "/" : local.normalized & "/"; + } else { + local.rv.webPath = Replace( + arguments.scriptName, + Reverse(SpanExcluding(Reverse(arguments.scriptName), "/")), + "" + ); + } + local.rv.rootPath = "/" & ListChangeDelims(local.rv.webPath, "/", "/"); + local.rv.rootcomponentPath = ListChangeDelims(local.rv.webPath, ".", "/"); + local.rv.wheelsComponentPath = ListAppend(local.rv.rootcomponentPath, "wheels", "."); + return local.rv; + } + + + /** + * Internal function. Rewrites a framework-relative include path (e.g. + * `/wheels/tests/app-runner.cfm`) so it resolves under a URL subpath + * install (issue #3251). The shipped app test-runner template includes + * the built-in app runner via an absolute `/wheels/...` path, which only + * resolves when the app is mounted at the web root; under a CommandBox + * multi-subfolder / IIS-subfolder topology the `/wheels` mapping does not + * resolve and the include fails. Prefixing the resolved `webPath` (the + * same subpath derivation as $resolveFrameworkPaths) makes the include + * work in both root and subfolder installs. Pure so it can be unit-tested + * in isolation. + */ + public string function $resolveSubpathInclude(required string template, string webPath) { + // Default to the app's resolved webPath without a runtime default-arg + // expression (some engines evaluate those eagerly); callers in tests + // pass webPath explicitly. + local.wp = StructKeyExists(arguments, "webPath") ? arguments.webPath : application.wheels.webPath; + local.base = Len(local.wp) ? local.wp : "/"; + if (Right(local.base, 1) != "/") { + local.base &= "/"; + } + // Strip any leading slash(es) from the framework-relative template so + // the join produces a single boundary slash. Anchored to the start so + // it never touches interior path separators. + local.relative = ReReplace(arguments.template, "^/+", ""); + return local.base & local.relative; + } + + + /** + * Internal function. Builds the debug bar's base reload URL (issue #3344). + * The base is composed from the resolved `webPath` plus the front-controller + * filename — the same idiom `urlFor()` uses — instead of raw + * `cgi.script_name`, so subfolder (subpath) installs emit links like + * `/myapp/posts?reload=` rather than `/myapp/public/index.cfm/posts?reload=` + * (which the user's rewrite rules don't route). The caller selects which + * path_info to pass (`request.cgi.path_info` when available, `cgi.path_info` + * otherwise — engines report it differently). `webPath` and `rewriteFile` + * default from application scope; tests pass them explicitly, and early + * boot/error paths where they're missing fall back to the raw script name + * (the pre-#3344 behavior). Pure string logic so it can be unit-tested in + * isolation. + */ + public string function $buildDebugReloadUrl( + required string scriptName, + string pathInfo = "", + string queryString = "", + string webPath, + string rewriteFile + ) { + // Resolve webPath/rewriteFile from application scope unless overridden. + // No runtime default-arg expressions (some engines evaluate those + // eagerly) — same pattern as $resolveSubpathInclude. + if (StructKeyExists(arguments, "webPath")) { + local.resolvedWebPath = arguments.webPath; + } else if (IsDefined("application.wheels.webPath")) { + local.resolvedWebPath = application.wheels.webPath; + } else { + local.resolvedWebPath = ""; + } + if (StructKeyExists(arguments, "rewriteFile")) { + local.resolvedRewriteFile = arguments.rewriteFile; + } else if (IsDefined("application.wheels.rewriteFile")) { + local.resolvedRewriteFile = application.wheels.rewriteFile; + } else { + local.resolvedRewriteFile = ""; + } + + // Base: webPath + front-controller filename (matches urlFor()); fall + // back to the raw script name when webPath isn't resolved yet. + if (Len(local.resolvedWebPath)) { + local.rv = local.resolvedWebPath & ListLast(arguments.scriptName, "/"); + } else { + local.rv = arguments.scriptName; + } + if (arguments.pathInfo != arguments.scriptName) { + local.rv &= arguments.pathInfo; + } + if (Len(arguments.queryString)) { + local.rv &= "?" & arguments.queryString; + } + if (Len(local.resolvedRewriteFile)) { + local.rv = ReplaceNoCase(local.rv, "/" & local.resolvedRewriteFile, ""); + } + local.reloadTokens = "development,testing,maintenance,production,true"; + local.iEnd = ListLen(local.reloadTokens); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.token = ListGetAt(local.reloadTokens, local.i); + local.rv = ReplaceNoCase( + ReplaceNoCase(local.rv, "?reload=" & local.token, ""), + "&reload=" & local.token, + "" + ); + } + if (Find("?", local.rv)) { + local.rv &= "&"; + } else { + local.rv &= "?"; + } + local.rv &= "reload="; + return local.rv; + } + + + /** + * Abort when the requested template is nested deeper than + * `vendor/wheels/Global.cfc`. The depth check MUST use + * `ExpandPath("/wheels/Global.cfc")`, not `GetCurrentTemplatePath()`. + * + * This function lives in a component-body include. Lucee compiles it + * as a UDF of `/wheels/global/request.cfm`, so `GetCurrentTemplatePath()` + * is that mapping-absolute include (3 path segments). The front + * controller is `.../public/index.cfm` (many more segments), so the + * old comparison treated every normal request as invalid, ran the + * 404/`onmissingtemplate` path, and — with `$include` also compiled + * from an include — 500'd `onAbort` (Lucee 7 smokes, issue ##3241). + * `ExpandPath("/wheels/Global.cfc")` is the same filesystem path + * `GetCurrentTemplatePath()` returned when this method lived on + * Global.cfc itself. + */ + public void function $abortInvalidRequest() { + local.applicationPath = Replace(ExpandPath("/wheels/Global.cfc"), "\", "/", "all"); + local.callingPath = Replace(GetBaseTemplatePath(), "\", "/", "all"); + if ( + !(GetFileFromPath(local.callingPath) == "runner.cfm") + && + ListLen(local.callingPath, "/") > ListLen(local.applicationPath, "/") + ) { + if (StructKeyExists(application, "wheels")) { + if (StructKeyExists(application.wheels, "showErrorInformation") && !application.wheels.showErrorInformation) { + $header(statusCode = 404); + } + if (StructKeyExists(application.wheels, "eventPath")) { + $includeAndOutput(template = "#application.wheels.eventPath#/onmissingtemplate.cfm"); + } + } + $header(statusCode = 404); + abort; + } + } + + + /** + * Throw a developer friendly Wheels error if set (typically in development mode). + * Otherwise show the 404 page for end users (typically in production mode). + */ + public void function $throwErrorOrShow404Page(required string type, required string message, string extendedInfo = "") { + $header(statusCode = 404); + if ($get("showErrorInformation")) { + Throw(type = arguments.type, message = arguments.message, extendedInfo = arguments.extendedInfo); + } else { + local.template = $get("eventPath") & "/onmissingtemplate.cfm"; + $includeAndOutput(template = local.template); + abort; + } + } + + + /** + * Returns the request timeout value in seconds. + * Must be safe to call during onError before application.wheels is initialized. + */ + public numeric function $getRequestTimeout() { + if ($hasEngineAdapter()) { + return $engineAdapter().getRequestTimeout(); + } + // Fallback when adapter not yet initialized (e.g. error during startup) + if (StructKeyExists(server, "boxlang")) { + return 10000; + } else if (StructKeyExists(server, "lucee")) { + return (GetPageContext().getRequestTimeout() / 1000); + } else { + return CreateObject("java", "coldfusion.runtime.RequestMonitor").GetRequestTimeout(); + } + } + + + /** + * Returns the engine adapter instance for centralized cross-engine behavior. + * Checks both application.wheels (post-init) and application.$wheels (during init). + */ + public any function $engineAdapter() { + if ( + StructKeyExists(application, "wheels") && IsStruct(application.wheels) && StructKeyExists( + application.wheels, + "engineAdapter" + ) + ) { + return application.wheels.engineAdapter; + } + if ( + StructKeyExists(application, "$wheels") && IsStruct(application.$wheels) && StructKeyExists( + application.$wheels, + "engineAdapter" + ) + ) { + return application.$wheels.engineAdapter; + } + Throw(type = "Wheels.EngineAdapterNotInitialized", message = "Engine adapter has not been initialized yet."); + } + + + /** + * Returns true if the engine adapter is available in application scope. + * Used by functions that may be called before onApplicationStart completes. + */ + public boolean function $hasEngineAdapter() { + return ( + StructKeyExists(application, "wheels") && IsStruct(application.wheels) && StructKeyExists( + application.wheels, + "engineAdapter" + ) + ) + || ( + StructKeyExists(application, "$wheels") && IsStruct(application.$wheels) && StructKeyExists( + application.$wheels, + "engineAdapter" + ) + ); + } + + + /** + * Creates a controller and calls an action on it. + * Which controller and action that's called is determined by the params passed in. + * Returns the result of the request either as a string or in a struct with `body`, `emails`, `files`, `flash`, `redirect`, `status`, and `type`. + * Primarily used for testing purposes. + * + * [section: Controller] + * [category: Miscellaneous Functions] + * + * @params The params struct to use in the request (make sure that at least `controller` and `action` are set). + * @method The HTTP method to use in the request (`get`, `post` etc). + * @returnAs Pass in `struct` to return all information about the request instead of just the final output (`body`). + * @rollback Pass in `true` to roll back all database transactions made during the request. + * @includeFilters Set to `before` to only execute "before" filters, `after` to only execute "after" filters or `false` to skip all filters. + */ + public any function processRequest( + required struct params, + string method, + string returnAs, + string rollback, + string includeFilters = true + ) { + $args(name = "processRequest", args = arguments); + + // Set the global transaction mode to rollback when specified. + // Also save the current state so we can set it back after the tests have run. + if (arguments.rollback) { + local.transactionMode = $get("transactionMode"); + $set(transactionMode = "rollback"); + } + + // Before proceeding we set the request method to our internal CGI scope if passed in. + // This way it's possible to mock a POST request so that an isPost() call in the action works as expected for example. + if (arguments.method != "get") { + request.cgi.request_method = arguments.method; + } + + // Look up controller & action via route name and method + if (StructKeyExists(arguments.params, "route")) { + local.route = $findRoute(argumentCollection = arguments.params, method = arguments.method); + arguments.params.controller = local.route.controller; + arguments.params.action = local.route.action; + } + + // Never deliver email or send files during test. + local.deliverEmail = $get(functionName = "sendEmail", name = "deliver"); + $set(functionName = "sendEmail", deliver = false); + local.deliverFile = $get(functionName = "sendFile", name = "deliver"); + $set(functionName = "sendFile", deliver = false); + + local.controller = controller(name = arguments.params.controller, params = arguments.params); + + // Set to ignore CSRF errors during testing. + local.controller.protectsFromForgery(with = "ignore"); + + local.controller.processAction(includeFilters = arguments.includeFilters); + local.response = local.controller.response(); + + // Get redirect info. + // If a delayed redirect was made we use the status code for that and set the body to a blank string. + // If not we use the current status code and response and set the redirect info to a blank string. + local.redirectDetails = local.controller.getRedirect(); + if (StructCount(local.redirectDetails)) { + local.body = ""; + local.redirect = local.redirectDetails.url; + local.status = local.redirectDetails.statusCode; + } else { + local.status = $statusCode(); + local.body = local.response; + local.redirect = ""; + } + + if (arguments.returnAs == "struct") { + local.rv = { + body = local.body, + emails = local.controller.getEmails(), + files = local.controller.getFiles(), + flash = local.controller.flash(), + redirect = local.redirect, + status = local.status, + type = $contentType() + }; + } else { + local.rv = local.body; + } + + // Clear the Flash so we can run several processAction calls without the Flash sticking around. + local.controller.$flashClear(); + + // Set back the global transaction mode to the previous value if it has been changed. + if (arguments.rollback) { + $set(transactionMode = local.transactionMode); + } + + // Set back the request method to GET (this is fine since the test suite is always run using GET). + request.cgi.request_method = "get"; + + // Set back email delivery setting to previous value. + $set(functionName = "sendEmail", deliver = local.deliverEmail); + $set(functionName = "sendFile", deliver = local.deliverFile); + + // Set back the status code to 200 so the test suite does not use the same code that the action that was tested did. + // If the test suite fails it will set the status code to 500 later. + $header(statusCode = 200); + + // Set the Content-Type header in case it was set to something else (e.g. application/json) during processing. + // It's fine to do this because we always want to return the test page as text/html. + $header(name = "Content-Type", value = "text/html", charset = "UTF-8"); + + return local.rv; + } + diff --git a/vendor/wheels/global/routing.cfm b/vendor/wheels/global/routing.cfm new file mode 100644 index 0000000000..7930fb3fe2 --- /dev/null +++ b/vendor/wheels/global/routing.cfm @@ -0,0 +1,531 @@ + +/** + * wheels.Global include: routing + * Routes, URLFor, mapper, and channel publish. + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + // ====================================================================== + // CHANNEL / PUB-SUB FUNCTIONS + // ====================================================================== + + /** + * Publish an event to a channel. + * Delegates to the in-memory Channel engine or the DatabaseAdapter + * depending on the adapter argument (or the global channelAdapter setting). + * + * Can be called from controllers, models, jobs, or anywhere with access + * to global helpers. + * + * [section: Global Helpers] + * [category: Channel Functions] + * + * @channel The channel name to publish to (e.g. "user.42"). + * @event The event type (e.g. "notification", "update"). + * @data The event data as a string (typically JSON). + * @adapter Adapter to use: "memory" (default) or "database". + */ + public struct function publish( + required string channel, + required string event, + required string data, + string adapter = "" + ) { + local.engine = $getChannelEngine(arguments.adapter); + return local.engine.publish(channel = arguments.channel, event = arguments.event, data = arguments.data); + } + + + /** + * Internal: Get or create the channel engine singleton for the given adapter type. + * Uses double-checked locking to ensure thread-safe lazy initialization. + * + * @adapter "memory" or "database". Defaults to application.wheels.channelAdapter or "memory". + */ + public any function $getChannelEngine(string adapter = "") { + // Resolve adapter type + if (!Len(arguments.adapter)) { + if (StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "channelAdapter")) { + local.adapterType = application.wheels.channelAdapter; + } else { + local.adapterType = "memory"; + } + } else { + local.adapterType = arguments.adapter; + } + + if (local.adapterType == "database") { + if (!StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "channelDatabaseEngine")) { + lock name="wheelsChannelDatabaseEngine" timeout="10" { + if (!StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "channelDatabaseEngine")) { + application.wheels.channelDatabaseEngine = CreateObject("component", "wheels.channel.DatabaseAdapter").init(); + } + } + } + return application.wheels.channelDatabaseEngine; + } + + // Default: memory adapter + if (!StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "channelEngine")) { + lock name="wheelsChannelEngine" timeout="10" { + if (!StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "channelEngine")) { + application.wheels.channelEngine = CreateObject("component", "wheels.Channel").init(); + } + } + } + return application.wheels.channelEngine; + } + + + // ====================================================================== + // ROUTING FUNCTIONS + // ====================================================================== + + /** + * Internal function. + */ + public string function $routeVariables() { + return $findRoute(argumentCollection = arguments).foundvariables; + } + + + /** + * Internal function. + */ + public struct function $findRoute() { + // Throw error if no route was found. + if (!StructKeyExists(application.wheels.namedRoutePositions, arguments.route)) { + $throwErrorOrShow404Page( + type = "Wheels.RouteNotFound", + message = "Could not find the `#arguments.route#` route.", + extendedInfo = "Make sure there is a route configured in your `config/routes.cfm` file named `#arguments.route#`." + ); + } + local.routePos = application.wheels.namedRoutePositions[arguments.route]; + if (Find(",", local.routePos)) { + // there are several routes with this name so we need to figure out which one to use by checking the passed in arguments + local.iEnd = ListLen(local.routePos); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.rv = application.wheels.routes[ListGetAt(local.routePos, local.i)]; + local.foundRoute = StructKeyExists(arguments, "method") && local.rv.methods == arguments.method; + local.jEnd = ListLen(local.rv.foundvariables); + for (local.j = 1; local.j <= local.jEnd; local.j++) { + local.variable = ListGetAt(local.rv.foundvariables, local.j); + if (!StructKeyExists(arguments, local.variable) || !Len(arguments[local.variable])) { + local.foundRoute = false; + } + } + if (local.foundRoute) { + break; + } + } + } else { + local.rv = application.wheels.routes[local.routePos]; + } + return local.rv; + } + + + /** + * Internal function. + */ + public any function $constructParams( + required string params, + boolean encode = true, + boolean $encodeForHtmlAttribute = false, + string $URLRewriting = application.wheels.URLRewriting + ) { + // When rewriting is off we will already have "?controller=" etc in the url so we have to continue with an ampersand. + if (arguments.$URLRewriting == "Off") { + local.delim = "&"; + } else { + local.delim = "?"; + } + + local.rv = ""; + local.paramsArray = ListToArray(arguments.params, "&"); + local.iEnd = ArrayLen(local.paramsArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.params = ListToArray(local.paramsArray[local.i], "="); + local.name = local.params[1]; + if (arguments.encode && $get("encodeURLs")) { + local.name = EncodeForURL($canonicalize(local.name)); + if (arguments.$encodeForHtmlAttribute) { + local.name = EncodeForHTMLAttribute(local.name); + } + } + local.rv &= local.delim & local.name & "="; + local.delim = "&"; + if (ArrayLen(local.params) == 2) { + local.value = local.params[2]; + if (arguments.encode && $get("encodeURLs")) { + local.value = EncodeForURL($canonicalize(local.value)); + if (arguments.$encodeForHtmlAttribute) { + local.value = EncodeForHTMLAttribute(local.value); + } + } + + // Obfuscate the param if set globally and we're not processing cfid or cftoken (can't touch those). + // Wrap in double quotes because in Lucee we have to pass it in as a string otherwise leading zeros are stripped. + if (application.wheels.obfuscateUrls && !ListFindNoCase("cfid,cftoken", local.name)) { + local.value = obfuscateParam("#local.value#"); + } + + local.rv &= local.value; + } + } + return local.rv; + } + + + /** + * Internal function. + */ + public string function $prependUrl(required string path, string host = "", string protocol = "", numeric port = 0) { + local.rv = arguments.path; + if (arguments.port != 0) { + // use the port that was passed in by the developer + local.rv = ":" & arguments.port & local.rv; + } else if (request.cgi.server_port != 80 && request.cgi.server_port != 443) { + // if the port currently in use is not 80 or 443 we set it explicitly in the URL + local.rv = ":" & request.cgi.server_port & local.rv; + } + if (Len(arguments.host)) { + local.rv = arguments.host & local.rv; + } else { + local.rv = request.cgi.server_name & local.rv; + } + if (Len(arguments.protocol)) { + local.rv = arguments.protocol & "://" & local.rv; + } else if (request.cgi.http_x_forwarded_proto == "https" || request.cgi.server_port_secure == "true") { + local.rv = "https://" & local.rv; + } else { + local.rv = "http://" & local.rv; + } + return local.rv; + } + + + /** + * Internal function. + */ + public void function $loadRoutes() { + $simpleLock(name = "$mapperLoadRoutes", type = "exclusive", timeout = 5, execute = "$lockedLoadRoutes"); + } + + + /** + * Internal function. + */ + public void function $lockedLoadRoutes() { + local.appKey = $appKey(); + // clear out the route info (including the static-route index so a reload + // can't serve stale first-write-wins entries from the previous route set) + ArrayClear(application[local.appKey].routes); + StructClear(application[local.appKey].namedRoutePositions); + if (StructKeyExists(application[local.appKey], "staticRoutes")) { + StructClear(application[local.appKey].staticRoutes); + } + // Drop the URLFor controller/action memo so cached lookups from the + // previous route set (including negative-cached misses) can't leak + // across a reload. `$addRoute` also clears the memo, but doing it + // here guarantees a freshly-reloaded app starts with an empty cache + // even before the first `$addRoute` call runs. + if (StructKeyExists(application[local.appKey], "urlForCache")) { + StructClear(application[local.appKey].urlForCache); + } + // load wheels internal gui routes + // TODO skip this if mode != development|testing? + $include(template = "/wheels/public/routes.cfm"); + // Browser-test fixture routes — opt-in, only mounted in testing/development. + // See `vendor/wheels/public/browser-fixtures/routes.cfm` and issues #2135, #2138. + // The fixture controllers live at `vendor/wheels/public/browser-fixtures/controllers/` + // and render their own views via explicit `$include`, so only `controllerPath` + // needs to be extended (viewPath is single-string and left alone). + if ( + StructKeyExists(application[local.appKey], "loadBrowserTestFixtures") + && application[local.appKey].loadBrowserTestFixtures + && StructKeyExists(application[local.appKey], "environment") + && ListFindNoCase("testing,development", application[local.appKey].environment) + ) { + local.fixtureControllerPath = "/wheels/public/browser-fixtures/controllers"; + if (!ListFindNoCase(application[local.appKey].controllerPath, local.fixtureControllerPath)) { + application[local.appKey].controllerPath = ListAppend( + application[local.appKey].controllerPath, + local.fixtureControllerPath + ); + } + $include(template = "/wheels/public/browser-fixtures/routes.cfm"); + } + // load developer routes next + $include(template = "/config/routes.cfm"); + // set lookup info for the named routes + $setNamedRoutePositions(); + } + + + /** + * Internal function. + */ + public void function $setNamedRoutePositions() { + local.appKey = $appKey(); + local.iEnd = ArrayLen(application[local.appKey].routes); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.route = application[local.appKey].routes[local.i]; + if (StructKeyExists(local.route, "name") && Len(local.route.name)) { + if (!StructKeyExists(application[local.appKey].namedRoutePositions, local.route.name)) { + application[local.appKey].namedRoutePositions[local.route.name] = ""; + } + application[local.appKey].namedRoutePositions[local.route.name] = ListAppend( + application[local.appKey].namedRoutePositions[local.route.name], + local.i + ); + } + } + } + + + /** + * Creates an internal URL based on supplied arguments. + * + * [section: Global Helpers] + * [category: Miscellaneous Functions] + * + * @route Name of a route that you have configured in `config/routes.cfm`. + * @controller Name of the controller to include in the URL. + * @action Name of the action to include in the URL. + * @key Key(s) to include in the URL. + * @params Any additional parameters to be set in the query string (example: `wheels=cool&x=y`). Please note that Wheels uses the `&` and `=` characters to split the parameters and encode them properly for you. However, if you need to pass in `&` or `=` as part of the value, then you need to encode them (and only them), example: `a=cats%26dogs%3Dtrouble!&b=1`. + * @anchor Sets an anchor name to be appended to the path. + * @onlyPath If `true`, returns only the relative URL (no protocol, host name or port). + * @host Set this to override the current host. + * @protocol Set this to override the current protocol. + * @port Set this to override the current port number. + * @encode Encode URL parameters using `EncodeForURL()`. Please note that this does not make the string safe for placement in HTML attributes, for that you need to wrap the result in `EncodeForHtmlAttribute()` or use `linkTo()`, `startFormTag()` etc instead. + */ + public string function URLFor( + string route = "", + string controller = "", + string action = "", + any key = "", + string params = "", + string anchor = "", + boolean onlyPath, + string host, + string protocol, + numeric port, + boolean encode, + boolean $encodeForHtmlAttribute = false, + string $URLRewriting = application.wheels.URLRewriting + ) { + $args(name = "URLFor", args = arguments); + local.coreVariables = "controller,action,key,format"; + local.params = {}; + if (StructKeyExists(variables, "params")) { + StructAppend(local.params, variables.params); + } + + // Throw error if host or protocol are passed with onlyPath=true. + local.hostOrProtocolNotEmpty = Len(arguments.host) || Len(arguments.protocol); + if (application.wheels.showErrorInformation && arguments.onlyPath && local.hostOrProtocolNotEmpty) { + Throw( + type = "Wheels.IncorrectArguments", + message = "Can't use the `host` or `protocol` arguments when `onlyPath` is `true`.", + extendedInfo = "Set `onlyPath` to `false` so that `linkTo` will create absolute URLs and thus allowing you to set the `host` and `protocol` on the link." + ); + } + + // Look up actual route paths instead of providing default Wheels path generation. + // Loop over all routes to find matching one, break the loop on first match. + // The (controller, action) → route-name memo lives in application scope and + // negative-caches misses (empty string sentinel) so wildcard-`[controller]` + // apps — where `$addRoute` strips the `controller` key, guaranteeing no + // match — don't re-scan the route table for every link helper. The cache + // is invalidated by `$addRoute` and `$lockedLoadRoutes`. + if (!Len(arguments.route) && Len(arguments.action)) { + if (!Len(arguments.controller)) { + arguments.controller = local.params.controller; + } + local.appKey = $appKey(); + if (!StructKeyExists(application[local.appKey], "urlForCache")) { + application[local.appKey].urlForCache = {}; + } + local.cache = application[local.appKey].urlForCache; + local.key = arguments.controller & "##" & arguments.action; + if (!StructKeyExists(local.cache, local.key)) { + local.found = ""; + local.iEnd = ArrayLen(application[local.appKey].routes); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.route = application[local.appKey].routes[local.i]; + local.controllerMatch = StructKeyExists(local.route, "controller") && local.route.controller == arguments.controller; + local.actionMatch = StructKeyExists(local.route, "action") && local.route.action == arguments.action; + if (local.controllerMatch && local.actionMatch) { + local.found = local.route.name; + break; + } + } + local.cache[local.key] = local.found; + } + if (Len(local.cache[local.key])) { + arguments.route = local.cache[local.key]; + } + } + + // Start building the URL to return by setting the sub folder path and script name portion. + // Script name index.cfm will be removed later if applicable (e.g. when URL rewriting is on). + local.rv = application.wheels.webPath & ListLast(request.cgi.script_name, "/"); + + // Look up route pattern to use and add it to the URL to return. + // Either from a passed in route or the Wheels default one. + // For the Wheels default we set the controller and action arguments to what's in the params struct. + if (Len(arguments.route)) { + local.route = $findRoute(argumentCollection = arguments); + local.foundVariables = local.route.foundvariables; + + if (arguments.$URLRewriting neq "Off") { + local.rv &= local.route.pattern; + } else { + // Always include core variables when not rewriting + local.foundVariables &= "," & local.coreVariables; + local.rv &= "?controller=[controller]&action=[action]&key=[key]&format=[format]"; + } + } else { + local.route = {}; + local.foundVariables = local.coreVariables; + local.rv &= "?controller=[controller]&action=[action]&key=[key]&format=[format]"; + } + + // Shared fallback logic for controller/action + if (StructKeyExists(local, "params")) { + // Handle action + if (!Len(arguments.action)) { + if (StructKeyExists(local.route, "action")) { + arguments.action = local.route.action; + } else if (Len(arguments.controller)) { + arguments.action = "index"; + } else if (StructKeyExists(local.params, "action")) { + arguments.action = local.params.action; + } + } + + // Handle controller + if (!Len(arguments.controller)) { + if (StructKeyExists(local.route, "controller")) { + arguments.controller = local.route.controller; + } else if (StructKeyExists(local.params, "controller")) { + arguments.controller = local.params.controller; + } + } + } + + // Replace each params variable with the correct value. + for (local.i = 1; local.i <= ListLen(local.foundVariables); local.i++) { + local.property = ListGetAt(local.foundVariables, local.i); + local.reg = "\[\*?#local.property#\]"; + + // Read necessary variables from different sources. + if (StructKeyExists(arguments, local.property) && Len(arguments[local.property])) { + local.value = arguments[local.property]; + } else if (StructKeyExists(local.route, local.property)) { + local.value = local.route[local.property]; + } else if (Len(arguments.route) && arguments.$URLRewriting != "Off") { + Throw( + type = "Wheels.IncorrectRoutingArguments", + message = "Incorrect Arguments", + extendedInfo = "The route chosen by Wheels `#local.route.name#` requires the argument `#local.property#`. Pass the argument `#local.property#` or change your routes to reflect the proper variables needed." + ); + } else { + continue; + } + + // If value is a model object, get its key value. + if (IsObject(local.value)) { + local.value = local.value.key(); + } + + // Any value we find from above, URL encode it here. + if (arguments.encode && $get("encodeURLs")) { + local.value = EncodeForURL($canonicalize(local.value)); + if (arguments.$encodeForHtmlAttribute) { + local.value = EncodeForHTMLAttribute(local.value); + } + } + + // If property is not in pattern, store it in the params argument. + if (!ReFind(local.reg, local.rv)) { + if (!ListFindNoCase(local.coreVariables, local.property)) { + arguments.params = ListAppend(arguments.params, "#local.property#=#local.value#", "&"); + } + continue; + } + + // Transform value before setting it in pattern. + if (local.property == "controller" || local.property == "action") { + local.value = hyphenize(local.value); + } else if (application.wheels.obfuscateUrls) { + local.value = obfuscateParam(local.value); + } + local.rv = ReReplace(local.rv, local.reg, local.value); + } + + // Clean up unused keys in pattern. + local.rv = ReReplace(local.rv, "((&|\?)\w+=|\/|\.)\[\*?\w+\]", "", "ALL"); + + // When URL rewriting is on (or partially) we replace the "?controller="" stuff in the URL with just "/". + if (arguments.$URLRewriting != "Off") { + local.rv = Replace(local.rv, "?controller=", "/"); + local.rv = Replace(local.rv, "&action=", "/"); + local.rv = Replace(local.rv, "&key=", "/"); + } + + // When URL rewriting is on we remove the rewrite file name (e.g. index.cfm) from the URL so it doesn't show. + // Also get rid of the double "/" that this removal typically causes. + if (arguments.$URLRewriting == "On") { + local.rv = Replace(local.rv, application.wheels.rewriteFile, ""); + local.rv = Replace(local.rv, "//", "/"); + } + + // Add params to the URL when supplied. + if (Len(arguments.params)) { + local.rv &= $constructParams( + params = arguments.params, + encode = arguments.encode, + $encodeForHtmlAttribute = arguments.$encodeForHtmlAttribute, + $URLRewriting = arguments.$URLRewriting + ); + } + + // Add an anchor to the the URL when supplied. + if (Len(arguments.anchor)) { + local.rv &= "##" & arguments.anchor; + } + + // Prepend the full URL if directed. + if (!arguments.onlyPath) { + local.rv = $prependUrl(path = local.rv, argumentCollection = arguments); + } + + return local.rv; + } + + + /** + * Returns the mapper object used to configure your application's routes. Usually you will use this method in `config/routes.cfm` to start chaining route mapping methods like `resources`, `namespace`, etc. + * + * [section: Configuration] + * [category: Routing] + * + * @restful Whether to turn on RESTful routing or not. Not recommended to set. Will probably be removed in a future version of wheels, as RESTful routes are the default. + * @methods If not RESTful, then specify allowed routes. Not recommended to set. Will probably be removed in a future version of wheels, as RESTful routes are the default. + * @mapFormat This is useful for providing formats via URL like `json`, `xml`, `pdf`, etc. Set to false to disable automatic .[format] generation for resource based routes + */ + public struct function mapper(boolean restful = true, boolean methods = arguments.restful, boolean mapFormat = true) { + return application[$appKey()].mapper.$draw(argumentCollection = arguments); + } + diff --git a/vendor/wheels/global/settings.cfm b/vendor/wheels/global/settings.cfm new file mode 100644 index 0000000000..5854cfbcf8 --- /dev/null +++ b/vendor/wheels/global/settings.cfm @@ -0,0 +1,216 @@ + +/** + * wheels.Global include: settings + * get / set / env and multi-tenant helpers. + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + /** + * Returns the current setting for the supplied Wheels setting or the current default for the supplied Wheels function argument. + * + * [section: Configuration] + * [category: Miscellaneous Functions] + * + * @name Variable name to get setting for. + * @functionName Function name to get setting for. + */ + public any function get(required string name, string functionName = "") { + return $get(argumentCollection = arguments); + } + + + /** + * Returns the value of an environment variable. Checks application.env (loaded from .env files) first, then falls back to system environment variables (server.system.environment). Returns the default if the variable is not found in either location. + * + * [section: Configuration] + * [category: Miscellaneous Functions] + * + * @name The environment variable name to look up. + * @defaultValue Value to return if the variable is not found. The legacy + * named argument `default` is also accepted for backwards compatibility + * with pre-rename callers. + */ + public any function env(required string name, any defaultValue = "") { + if (StructKeyExists(application, "env") && StructKeyExists(application.env, arguments.name)) { + return application.env[arguments.name]; + } + if ( + StructKeyExists(server, "system") + && StructKeyExists(server.system, "environment") + && StructKeyExists(server.system.environment, arguments.name) + ) { + return server.system.environment[arguments.name]; + } + // Back-compat for the legacy `default = "Y"` named-arg form. The + // parameter was renamed from `default` (a CFML reserved word Adobe CF + // refuses to bind) to `defaultValue`; named arguments still land in + // `arguments` under their literal key on every engine. + if (StructKeyExists(arguments, "default")) { + return arguments.default; + } + return arguments.defaultValue; + } + + + /** + * Use to configure a global setting or set a default for a function. + * + * [section: Configuration] + * [category: Miscellaneous Functions] + */ + public void function set() { + $set(argumentCollection = arguments); + } + + + /** + * Internal function. + * Called from get(). + */ + public any function $get(required string name, string functionName = "") { + // Multi-tenant config override: per-tenant settings take precedence + // over application-level settings (non-function settings only). + // Security-sensitive settings cannot be overridden per-tenant. + // Use a StructKeyExists chain for safe nested scope traversal during app + // startup (IsDefined string-parses its dotted-path argument on every call + // and $get runs on every settings read so it's too expensive here). + if ( + !Len(arguments.functionName) + && StructKeyExists(request, "wheels") + && StructKeyExists(request.wheels, "tenant") + && StructKeyExists(request.wheels.tenant, "config") + && StructKeyExists(request.wheels.tenant.config, arguments.name) + && !ListFindNoCase( + "encryptionAlgorithm,encryptionSecretKey,encryptionEncoding,CSRFProtection,csrfStore,reloadPassword,obfuscateUrls", + arguments.name + ) + ) { + return request.wheels.tenant.config[arguments.name]; + } + local.appKey = $appKey(); + if (Len(arguments.functionName)) { + local.rv = application[local.appKey].functions[arguments.functionName][arguments.name]; + } else { + local.rv = application[local.appKey][arguments.name]; + } + return local.rv; + } + + + /** + * Internal function. + * Called from set(). + */ + public void function $set() { + local.appKey = $appKey(); + if (ArrayLen(arguments) > 1) { + for (local.key in arguments) { + if (local.key != "functionName") { + local.functionNameArray = ListToArray(arguments.functionName); + local.iEnd = ArrayLen(local.functionNameArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.functionName = Trim(local.functionNameArray[local.i]); + application[local.appKey].functions[local.functionName][local.key] = arguments[local.key]; + } + } + } + } else { + application[local.appKey][StructKeyList(arguments)] = arguments[1]; + } + } + + + // ====================================================================== + // MULTI-TENANCY FUNCTIONS + // ====================================================================== + + /** + * Returns the current tenant struct, or an empty struct if no tenant is active. + * The tenant struct contains: `id`, `dataSource`, `config`, and `$locked`. + * + * A tenant only counts as active when it carries a non-empty `dataSource` — the same test + * `$tenantDataSource()` applies before it routes a query. Anything else on the key reads as + * no tenant rather than being handed back as though it were a resolved one, so a malformed + * value degrades to a no-op instead of wrong behaviour (#3336). Every framework producer + * (`switchTenant()`, `TenantResolver`, `Job.$restoreTenantContext()`, `TenantMigrator`) + * already guarantees a non-empty `dataSource`, so this only filters foreign values. + * + * [section: Configuration] + * [category: Multi-Tenancy] + */ + public struct function tenant() { + if ( + IsDefined("request.wheels.tenant") + && IsStruct(request.wheels.tenant) + && StructKeyExists(request.wheels.tenant, "dataSource") + && Len(request.wheels.tenant.dataSource) + ) { + return request.wheels.tenant; + } + return {}; + } + + + /** + * Returns the current tenant's datasource name, or the application default if no tenant is active. + * + * [section: Configuration] + * [category: Multi-Tenancy] + */ + public string function $tenantDataSource() { + if ( + IsDefined("request.wheels.tenant.dataSource") + && Len(request.wheels.tenant.dataSource) + ) { + return request.wheels.tenant.dataSource; + } + return $get("dataSourceName"); + } + + + /** + * Switches the active tenant mid-request. Throws if the current tenant is locked + * (set by TenantResolver middleware) unless `force` is true. + * + * [section: Configuration] + * [category: Multi-Tenancy] + * + * @tenant Struct with at minimum a `dataSource` key. Optional: `id`, `config`. + * @force If true, overrides the lock set by TenantResolver middleware. + */ + public void function switchTenant(required struct tenant, boolean force = false) { + if (!StructKeyExists(arguments.tenant, "dataSource") || !Len(arguments.tenant.dataSource)) { + Throw(type = "Wheels.InvalidTenant", message = "The tenant struct must contain a non-empty `dataSource` key."); + } + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + } + // Check if current tenant is locked + if ( + !arguments.force + && IsDefined("request.wheels.tenant") + && StructKeyExists(request.wheels.tenant, "$locked") + && request.wheels.tenant["$locked"] + ) { + Throw( + type = "Wheels.TenantLocked", + message = "Cannot switch tenants mid-request. The current tenant was set by middleware and is locked.", + extendedInfo = "Use `switchTenant(tenant={...}, force=true)` to override, or remove the lock in your middleware configuration." + ); + } + // Set defaults + if (!StructKeyExists(arguments.tenant, "id")) { + arguments.tenant.id = ""; + } + if (!StructKeyExists(arguments.tenant, "config")) { + arguments.tenant.config = {}; + } + request.wheels.tenant = arguments.tenant; + } + diff --git a/vendor/wheels/global/strings.cfm b/vendor/wheels/global/strings.cfm new file mode 100644 index 0000000000..8f710f4e72 --- /dev/null +++ b/vendor/wheels/global/strings.cfm @@ -0,0 +1,463 @@ + +/** + * wheels.Global include: strings + * Inflection, truncation, and time-in-words helpers. + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + // ====================================================================== + // TEXT FUNCTIONS + // ====================================================================== + + /** + * Internal function. + */ + public string function $singularizeOrPluralize( + required string text, + required string which, + numeric count = -1, + boolean returnCount = true + ) { + // by default we pluralize/singularize the entire string + local.text = arguments.text; + + // keep track of the success of any rule matches + local.ruleMatched = false; + + // when count is 1 we don't need to pluralize at all so just set the return value to the input string + local.rv = local.text; + + if (arguments.count != 1) { + if (ReFind("[A-Z]", local.text)) { + // only pluralize/singularize the last part of a camelCased variable (e.g. in "websiteStatusUpdate" we only change the "update" part) + // also set a variable with the unchanged part of the string (to be prepended before returning final result) + local.upperCasePos = ReFind("[A-Z]", Reverse(local.text)); + local.prepend = Mid(local.text, 1, Len(local.text) - local.upperCasePos); + local.text = Reverse(Mid(Reverse(local.text), 1, local.upperCasePos)); + } + + // Get global settings for uncountable and irregular words. + // For the irregular ones we need to convert them from a struct to a list. + local.uncountables = $listClean($get("uncountables")); + local.irregulars = ""; + local.words = $get("irregulars"); + for (local.word in local.words) { + local.irregulars = ListAppend(local.irregulars, LCase(local.word)); + local.irregulars = ListAppend(local.irregulars, local.words[local.word]); + } + + if (ListFindNoCase(local.uncountables, local.text)) { + local.rv = local.text; + local.ruleMatched = true; + } else if (ListFindNoCase(local.irregulars, local.text)) { + local.pos = ListFindNoCase(local.irregulars, local.text); + if (arguments.which == "singularize" && local.pos % 2 == 0) { + local.rv = ListGetAt(local.irregulars, local.pos - 1); + } else if (arguments.which == "pluralize" && local.pos % 2 != 0) { + local.rv = ListGetAt(local.irregulars, local.pos + 1); + } else { + local.rv = local.text; + } + local.ruleMatched = true; + } else { + if (arguments.which == "pluralize") { + local.ruleList = "(quiz)$,\1zes,^(ox)$,\1en,([m|l])ouse$,\1ice,(matr|vert|ind)ix|ex$,\1ices,(x|ch|ss|sh)$,\1es,([^aeiouy]|qu)y$,\1ies,(hive)$,\1s,(?:([^f])fe|([lr])f)$,\1\2ves,sis$,ses,([ti])um$,\1a,(buffal|tomat|potat|volcan|her)o$,\1oes,(bu)s$,\1ses,(alias|status)$,\1es,(octop|vir)us$,\1i,(ax|test)is$,\1es,s$,s,$,s"; + } else if (arguments.which == "singularize") { + local.ruleList = "(quiz)zes$,\1,(matr)ices$,\1ix,(vert|ind)ices$,\1ex,^(ox)en,\1,(alias|status)es$,\1,([octop|vir])i$,\1us,(cris|ax|test)es$,\1is,(shoe)s$,\1,(o)es$,\1,(bus)es$,\1,([m|l])ice$,\1ouse,(x|ch|ss|sh)es$,\1,(m)ovies$,\1ovie,(s)eries$,\1eries,([^aeiouy]|qu)ies$,\1y,([lr])ves$,\1f,(tive)s$,\1,(hive)s$,\1,([^f])ves$,\1fe,(^analy)ses$,\1sis,((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$,\1\2sis,([ti])a$,\1um,(n)ews$,\1ews,(.*)?ss$,\1ss,s$,#Chr(7)#"; + } + local.rules = ArrayNew(2); + local.count = 1; + local.iEnd = ListLen(local.ruleList); + for (local.i = 1; local.i <= local.iEnd; local.i = local.i + 2) { + local.rules[local.count][1] = ListGetAt(local.ruleList, local.i); + local.rules[local.count][2] = ListGetAt(local.ruleList, local.i + 1); + local.count = local.count + 1; + } + local.iEnd = ArrayLen(local.rules); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + if (ReFindNoCase(local.rules[local.i][1], local.text)) { + local.rv = ReReplaceNoCase(local.text, local.rules[local.i][1], local.rules[local.i][2]); + local.ruleMatched = true; + break; + } + } + local.rv = Replace(local.rv, Chr(7), "", "all"); + } + + // this was a camelCased string and we need to prepend the unchanged part to the result + if (StructKeyExists(local, "prepend") && local.ruleMatched) { + local.rv = local.prepend & local.rv; + } + } + + // return the count number in the string (e.g. "5 sites" instead of just "sites") + if (arguments.returnCount && arguments.count != -1) { + local.rv = LsNumberFormat(arguments.count) & " " & local.rv; + } + return local.rv; + } + + + /** + * Capitalizes the first character of the supplied string. + * + * [section: Global Helpers] + * [category: String Functions] + * + * @text String to capitalize. + */ + public string function capitalize(required string text) { + local.rv = arguments.text; + if (Len(local.rv)) { + local.rv = UCase(Left(local.rv, 1)) & Mid(local.rv, 2, Len(local.rv) - 1); + } + return local.rv; + } + + + /** + * Returns readable text by capitalizing and converting camel casing to multiple words. + * + * [section: Global Helpers] + * [category: String Functions] + * + * @text Text to humanize. + * @except A list of strings (space separated) to replace within the output. + * + */ + public string function humanize(required string text, string except = "") { + // add a space before every capitalized word + local.rv = ReReplace(arguments.text, "([[:upper:]])", " \1", "all"); + + // remove space after punctuation chars + local.rv = ReReplace(local.rv, "([[:punct:]])([[:space:]])", "\1", "all"); + + // fix abbreviations so they form a word again (example: aURLVariable) + local.rv = ReReplace(local.rv, "([[:upper:]]) ([[:upper:]])(?:\s|\b)", "\1\2", "all"); + local.rv = ReReplace(local.rv, "([[:upper:]])([[:upper:]])([[:lower:]])", "\1\2 \3", "all"); + + if (Len(arguments.except)) { + local.exceptKeysArray = ListToArray(arguments.except, " "); + local.iEnd = ArrayLen(local.exceptKeysArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.item = local.exceptKeysArray[local.i]; + local.rv = ReReplaceNoCase(local.rv, "#local.item#(?:\b)", "#local.item#", "all"); + } + } + + // support multiple word input by stripping out all double spaces created + local.rv = Replace(local.rv, " ", " ", "all"); + + // capitalize the first letter and trim final result (which removes the leading space that happens if the string starts with an upper case character) + local.rv = Trim(capitalize(local.rv)); + return local.rv; + } + + + /** + * Returns the plural form of the passed in word. Can also pluralize a word based on a value passed to the `count` argument. Wheels stores a list of words that are the same in both singular and plural form (e.g. "equipment", "information") and words that don't follow the regular pluralization rules (e.g. "child" / "children", "foot" / "feet"). Use `get("uncountables")` / `set("uncountables", newList)` and `get("irregulars")` / `set("irregulars", newList)` to modify them to suit your needs. + * + * [section: Global Helpers] + * [category: String Functions] + * + * @word The word to pluralize. + * @count Pluralization will occur when this value is not 1. + * @returnCount Will return count prepended to the pluralization when true and count is not -1. + */ + public string function pluralize(required string word, numeric count = "-1", boolean returnCount = "true") { + return $singularizeOrPluralize( + count = arguments.count, + returnCount = arguments.returnCount, + text = arguments.word, + which = "pluralize" + ); + } + + + /** + * Returns the singular form of the passed in word. + * + * [section: Global Helpers] + * [category: String Functions] + * + * @word The word to singularize. + */ + public string function singularize(required string word) { + return $singularizeOrPluralize(text = arguments.word, which = "singularize"); + } + + + /** + * Converts camelCase strings to lowercase strings with hyphens as word delimiters instead. Example: myVariable becomes my-variable. + * + * [section: Global Helpers] + * [category: String Functions] + * + * @string The string to hyphenize. + */ + public string function hyphenize(required string string) { + local.rv = ReReplace(arguments.string, "([A-Z][a-z])", "-\l\1", "all"); + local.rv = ReReplace(local.rv, "([a-z])([A-Z])", "\1-\l\2", "all"); + local.rv = ReReplace(local.rv, "^-", "", "one"); + local.rv = LCase(local.rv); + return local.rv; + } + + + /** + * Capitalizes all words in the text to create a nicer looking title. + * + * [section: Global Helpers] + * [category: String Functions] + * + * @word The text to turn into a title. + */ + public string function titleize(required string word) { + local.rv = ""; + local.iEnd = ListLen(arguments.word, " "); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.rv = ListAppend(local.rv, capitalize(ListGetAt(arguments.word, local.i, " ")), " "); + } + return local.rv; + } + + + /** + * Truncates text to the specified length and replaces the last characters with the specified truncate string (which defaults to "..."). + * + * [section: Global Helpers] + * [category: String Functions] + * + * @text The text to truncate. + * @length Length to truncate the text to. + * @truncateString String to replace the last characters with. + */ + public string function truncate(required string text, numeric length, string truncateString) { + $args(name = "truncate", args = arguments); + if (Len(arguments.text) > arguments.length) { + local.rv = Left(arguments.text, arguments.length - Len(arguments.truncateString)) & arguments.truncateString; + } else { + local.rv = arguments.text; + } + return local.rv; + } + + + /** + * Truncates text to the specified length of words and replaces the remaining characters with the specified truncate string (which defaults to "..."). + * + * [section: Global Helpers] + * [category: String Functions] + * + * @text The text to truncate. + * @length Number of words to truncate the text to. + * @truncateString String to replace the last characters with. + */ + public string function wordTruncate(required string text, numeric length, string truncateString) { + $args(name = "wordTruncate", args = arguments); + local.words = ListToArray(arguments.text, " ", false); + + // When there are fewer (or same) words in the string than the number to be truncated we can just return it unchanged. + if (ArrayLen(local.words) <= arguments.length) { + return arguments.text; + } + + local.rv = ""; + local.iEnd = arguments.length; + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.rv = ListAppend(local.rv, local.words[local.i], " "); + } + local.rv &= arguments.truncateString; + return local.rv; + } + + + /** + * Extracts an excerpt from text that matches the first instance of a given phrase. + * + * [section: Global Helpers] + * [category: String Functions] + * + * @text The text to extract an excerpt from. + * @phrase The phrase to extract. + * @radius Number of characters to extract surrounding the phrase. + * @excerptString String to replace first and / or last characters with. + */ + public string function excerpt(required string text, required string phrase, numeric radius, string excerptString) { + $args(name = "excerpt", args = arguments); + local.pos = FindNoCase(arguments.phrase, arguments.text, 1); + + // Return an empty value if the text wasn't found at all. + if (!local.pos) { + return ""; + } + + // Set start info based on whether the excerpt text found, including its radius, comes before the start of the string. + if ((local.pos - arguments.radius) <= 1) { + local.startPos = 1; + local.truncateStart = ""; + } else { + local.startPos = local.pos - arguments.radius; + local.truncateStart = arguments.excerptString; + } + + // Set end info based on whether the excerpt text found, including its radius, comes after the end of the string. + if ((local.pos + Len(arguments.phrase) + arguments.radius) > Len(arguments.text)) { + local.endPos = Len(arguments.text); + local.truncateEnd = ""; + } else { + local.endPos = local.pos + arguments.radius; + local.truncateEnd = arguments.excerptString; + } + + local.len = (local.endPos + Len(arguments.phrase)) - local.startPos; + local.mid = Mid(arguments.text, local.startPos, local.len); + local.rv = local.truncateStart & local.mid & local.truncateEnd; + return local.rv; + } + + + // ====================================================================== + // DATETIME FUNCTIONS + // ====================================================================== + + /** + * Internal function. + */ + public string function $timestamp(string timeStampMode = application.wheels.timeStampMode) { + switch (arguments.timeStampMode) { + case "utc": + local.rv = DateConvert("local2Utc", Now()); + break; + case "local": + local.rv = Now(); + break; + case "epoch": + local.rv = Now().getTime(); + break; + default: + Throw(type = "Wheels.InvalidTimeStampMode", message = "Timestamp mode #arguments.timeStampMode# is invalid"); + } + + // Ensure adapterName is set (may not be if no model has been called yet) + if (!StructKeyExists(application[$appKey()], "adapterName")) { + local.dbType = $getDBType(); + $set(adapterName = "#local.dbType#Model"); + } + + // SQLite stores datetimes as TEXT. Format as a clean ISO-8601 string + // (no surrounding quotes — those are SQL-literal syntax, not data) so + // the value lands in the TEXT column verbatim and round-trips through + // IsDate/DateFormat without quote-stripping. + if ($get("adapterName") == "SQLiteModel") { + if (IsDate(local.rv)) { + local.rv = DateFormat(local.rv, "yyyy-mm-dd") & " " & TimeFormat(local.rv, "HH:mm:ss"); + } + } + + return local.rv; + } + + + /** + * Pass in two dates to this method, and it will return a string describing the difference between them. + * + * [section: Global Helpers] + * [category: Date Functions] + * + * @fromTime Date to compare from. + * @toTime Date to compare to. + * @includeSeconds Whether or not to include the number of seconds in the returned string. + */ + public string function distanceOfTimeInWords(required date fromTime, required date toTime, boolean includeSeconds) { + $args(name = "distanceOfTimeInWords", args = arguments); + local.minuteDiff = DateDiff("n", arguments.fromTime, arguments.toTime); + local.secondDiff = DateDiff("s", arguments.fromTime, arguments.toTime); + local.hours = 0; + local.days = 0; + local.rv = ""; + if (local.minuteDiff <= 1) { + if (local.secondDiff < 60) { + local.rv = "less than a minute"; + } else { + local.rv = "1 minute"; + } + if (arguments.includeSeconds) { + if (local.secondDiff < 5) { + local.rv = "less than 5 seconds"; + } else if (local.secondDiff < 10) { + local.rv = "less than 10 seconds"; + } else if (local.secondDiff < 20) { + local.rv = "less than 20 seconds"; + } else if (local.secondDiff < 40) { + local.rv = "half a minute"; + } + } + } else if (local.minuteDiff < 45) { + local.rv = local.minuteDiff & " minutes"; + } else if (local.minuteDiff < 90) { + local.rv = "about 1 hour"; + } else if (local.minuteDiff < 1440) { + local.hours = Ceiling(local.minuteDiff / 60); + local.rv = "about " & local.hours & " hours"; + } else if (local.minuteDiff < 2880) { + local.rv = "1 day"; + } else if (local.minuteDiff < 43200) { + local.days = Int(local.minuteDiff / 1440); + local.rv = local.days & " days"; + } else if (local.minuteDiff < 86400) { + local.rv = "about 1 month"; + } else if (local.minuteDiff < 525600) { + local.months = Int(local.minuteDiff / 43200); + local.rv = local.months & " months"; + } else if (local.minuteDiff < 657000) { + local.rv = "about 1 year"; + } else if (local.minuteDiff < 919800) { + local.rv = "over 1 year"; + } else if (local.minuteDiff < 1051200) { + local.rv = "almost 2 years"; + } else if (local.minuteDiff >= 1051200) { + local.years = Int(local.minuteDiff / 525600); + local.rv = "over " & local.years & " years"; + } + return local.rv; + } + + + /** + * Returns a string describing the approximate time difference between the date passed in and the current date. + * + * [section: Global Helpers] + * [category: Date Functions] + * + * @fromTime Date to compare from. + * @includeSeconds Whether or not to include the number of seconds in the returned string. + * @toTime Date to compare to. + */ + public any function timeAgoInWords(required date fromTime, boolean includeSeconds, date toTime = Now()) { + $args(name = "timeAgoInWords", args = arguments); + return distanceOfTimeInWords(argumentCollection = arguments); + } + + + /** + * Returns a string describing the approximate time difference between the current date and the date passed in. + * + * [section: Global Helpers] + * [category: Date Functions] + * + * @toTime Date to compare to. + * @includeSeconds Whether or not to include the number of seconds in the returned string. + * @fromTime Date to compare from. + */ + public string function timeUntilInWords(required date toTime, boolean includeSeconds, date fromTime = Now()) { + $args(name = "timeUntilInWords", args = arguments); + return distanceOfTimeInWords(argumentCollection = arguments); + } + diff --git a/vendor/wheels/global/tags.cfm b/vendor/wheels/global/tags.cfm new file mode 100644 index 0000000000..d84308d0ba --- /dev/null +++ b/vendor/wheels/global/tags.cfm @@ -0,0 +1,516 @@ + +/** + * wheels.Global include: tags + * CFML tag wrappers (cfheader, cfmail, cfinclude, cfdbinfo, …). + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + public struct function $image() { + local.rv = {}; + if (arguments.action == "info") { + local.rv = $engineAdapter().imageInfo(arguments.source); + } else if ($engineAdapter().isBoxLang()) { + Throw( + type = "Wheels.Image.UnsupportedAction", + message = "The `$image()` function in BoxLang currently supports only the 'info' action." + ); + } else { + // Adobe or Lucee: use cfimage + arguments.structName = "rv"; + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cfimage(attributeCollection = local.args); + local.rv = local.rv; + } + return local.rv; + } + + + public void function $mail() { + if (StructKeyExists(arguments, "mailparts")) { + local.mailparts = arguments.mailparts; + StructDelete(arguments, "mailparts"); + } + if (StructKeyExists(arguments, "mailparams")) { + local.mailparams = arguments.mailparams; + StructDelete(arguments, "mailparams"); + } + if (StructKeyExists(arguments, "tagContent")) { + local.tagContent = arguments.tagContent; + StructDelete(arguments, "tagContent"); + } + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cfmail(attributeCollection = "#local.args#") { + if (StructKeyExists(local, "mailparams")) { + for (local.i in local.mailparams) { + cfmailparam(attributeCollection = "#local.i#"); + } + } + if (StructKeyExists(local, "mailparts")) { + for (local.i in local.mailparts) { + local.innerTagContent = local.i.tagContent; + StructDelete(local.i, "tagContent"); + cfmailpart(attributeCollection = "#local.i#") { + WriteOutput(local.innerTagContent) + } + } + } + if (StructKeyExists(local, "tagContent")) { + WriteOutput(local.tagContent) + } + } + } + + + public any function $cache() { + // If cache is found only the function is aborted, not page. ---> + variables.$instance.reCache = false; + // Engines without the `cfcache` built-in (e.g. RustCFML) can't back + // the template/static cache. Degrade to a no-op: leaving reCache=true + // means the request still renders normally, just without this layer. + if ($hasEngineAdapter() && !$engineAdapter().supportsCfcache()) { + variables.$instance.reCache = true; + return; + } + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cfcache(attributeCollection = "#local.args#"); + variables.$instance.reCache = true; + } + + + public void function $content() { + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + // Best-effort: cfcontent throws on a committed response (Adobe CF). + if ($responseCommitted()) { + return; + } + try { + cfcontent(attributeCollection = "#local.args#"); + } catch (any e) { + // Re-probe to handle the isCommitted/throw race; rethrow only when + // the response is still uncommitted (a genuine caller error). + if (!$responseCommitted()) { + rethrow; + } + } + } + + + public void function $header() { + // Plain-struct copy: Adobe CF 2023+ rejects `arguments` as + // attributeCollection (#10 cross-engine invariant). `statusText` is + // stripped because Adobe CF 2025 removed it. + local.args = {}; + for (local.key in arguments) { + if (local.key != "statusText") { + local.args[local.key] = arguments[local.key]; + } + } + // Best-effort: cfheader throws on a committed response (Adobe CF). The + // short-circuit is critical inside onError, where letting the exception + // escape would replace the original error with the cfheader-failure stack. + if ($responseCommitted()) { + return; + } + try { + cfheader(attributeCollection = "#local.args#"); + } catch (any e) { + // Re-probe to handle the isCommitted/throw race; rethrow only when + // the response is still uncommitted (a genuine caller error). + if (!$responseCommitted()) { + rethrow; + } + } + } + + + /** + * Returns true when the servlet response has been committed and headers + * can no longer be modified. Returns false on engines or contexts where + * the underlying servlet probe is unavailable. + */ + public boolean function $responseCommitted() { + try { + return GetPageContext().getResponse().isCommitted(); + } catch (any e) { + return false; + } + } + + + public any function $directory() { + local.rv = ""; + arguments.name = "rv"; + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cfdirectory(attributeCollection = "#local.args#"); + return local.rv; + } + + + public any function $file() { + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cffile(attributeCollection = "#local.args#"); + } + + + public any function $cfinvoke(required string component, required string method, struct invokeArguments) { + cfinvoke + component = "#arguments.component#" + method = "#arguments.method#" + returnVariable = "#arguments.returnVariable#" + argumentCollection = "#arguments.invokeArguments#"; + return local.rv; + } + + + public any function $invoke() { + arguments.returnVariable = "local.rv"; + if (StructKeyExists(arguments, "componentReference")) { + arguments.component = arguments.componentReference; + StructDelete(arguments, "componentReference"); + } else if (NOT StructKeyExists(variables, arguments.method)) { + // this is done so that we can call dynamic methods via "onMissingMethod" on the object (we need to pass in the object for this so it can call methods on the "this" scope instead) + arguments.component = this; + } + if (StructKeyExists(arguments, "invokeArgs")) { + arguments.argumentCollection = arguments.invokeArgs; + if (StructCount(arguments.argumentCollection) IS NOT ListLen(StructKeyList(arguments.argumentCollection))) { + // work-around for fasthashremoved cf8 bug + arguments.argumentCollection = StructNew(); + for (local.i in StructKeyList(arguments.invokeArgs)) { + arguments.argumentCollection[local.i] = arguments.invokeArgs[local.i]; + } + } + + + if (StructKeyExists(arguments.invokeArgs, "componentReference")) { + arguments.component = arguments.invokeArgs.componentReference; + } + + + StructDelete(arguments, "invokeArgs"); + } + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cfinvoke(attributeCollection = "#local.args#"); + if (StructKeyExists(local, "rv")) { + return local.rv; + } + } + + + public void function $location(boolean delay = false) { + StructDelete(arguments, "$args", false); + if (NOT arguments.delay) { + StructDelete(arguments, "delay", false); + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cflocation(attributeCollection = "#local.args#"); + } + } + + + public void function $htmlhead() { + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + // Best-effort: cfhtmlhead throws "Unable to add text to HTML HEAD tag" + // on a committed response (Adobe CF). Same defensive shape as $header(). + if ($responseCommitted()) { + return; + } + try { + cfhtmlhead(attributeCollection = "#local.args#"); + } catch (any e) { + // Re-probe to handle the isCommitted/throw race; rethrow only when + // the response is still uncommitted (a genuine caller error). + if (!$responseCommitted()) { + rethrow; + } + } + } + + + public any function $dbinfo() { + arguments.name = "local.rv"; + if (StructKeyExists(arguments, "username") && !Len(arguments.username)) { + StructDelete(arguments, "username"); + } + if (StructKeyExists(arguments, "password") && !Len(arguments.password)) { + StructDelete(arguments, "password"); + } + + // BoxLang specific fix for index queries (MSSQL/Oracle) + if ( + $engineAdapter().isBoxLang() && + StructKeyExists(arguments, "type") && arguments.type == "index" && + StructKeyExists(arguments, "table") + ) { + local.adapter = $get("adapterName"); + + if (local.adapter == "MicrosoftSQLServerModel") { + local.sql = " + SELECT + DB_NAME() AS TABLE_CAT, + SCHEMA_NAME(t.schema_id) AS TABLE_SCHEM, + t.name AS TABLE_NAME, + CAST(CASE WHEN i.is_unique = 0 THEN 1 ELSE 0 END AS INT) AS NON_UNIQUE, + t.name AS INDEX_QUALIFIER, + i.name AS INDEX_NAME, + CASE + WHEN i.type = 1 THEN 'Clustered Index' + WHEN i.type = 2 THEN 'Other Index' + ELSE 'Other Index' + END AS TYPE, + CAST(ic.key_ordinal AS INT) AS ORDINAL_POSITION, + c.name AS COLUMN_NAME, + CASE WHEN ic.is_descending_key = 0 THEN 'A' ELSE 'D' END AS ASC_OR_DESC, + CAST(0 AS INT) AS CARDINALITY, + CAST(0 AS INT) AS PAGES, + '' AS FILTER_CONDITION + FROM sys.indexes i + INNER JOIN sys.objects t ON i.object_id = t.object_id + INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id + INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id + WHERE t.name = '#arguments.table#' + AND t.type = 'U' + AND i.type_desc IN ('CLUSTERED', 'NONCLUSTERED') + ORDER BY i.name, ic.key_ordinal + "; + local.rv = $query(sql = local.sql, datasource = arguments.datasource); + return local.rv; + } + + if (local.adapter == "OracleModel") { + local.sql = " + SELECT + NULL AS TABLE_CAT, + ai.OWNER AS TABLE_SCHEM, + ai.TABLE_NAME, + CASE WHEN ai.UNIQUENESS = 'NONUNIQUE' THEN 1 ELSE 0 END AS NON_UNIQUE, + ai.OWNER AS INDEX_QUALIFIER, + ai.INDEX_NAME, + 'Other Index' AS TYPE, + ac.COLUMN_POSITION AS ORDINAL_POSITION, + ac.COLUMN_NAME, + CASE WHEN ac.DESCEND = 'DESC' THEN 'D' ELSE 'A' END AS ASC_OR_DESC, + 0 AS CARDINALITY, + 0 AS PAGES, + '' AS FILTER_CONDITION + FROM ALL_INDEXES ai + JOIN ALL_IND_COLUMNS ac ON ai.INDEX_NAME = ac.INDEX_NAME AND ai.OWNER = ac.INDEX_OWNER + WHERE ai.TABLE_NAME = UPPER('#arguments.table#') + AND ai.INDEX_TYPE != 'LOB' + ORDER BY ai.INDEX_NAME, ac.COLUMN_POSITION + "; + local.rv = $query(sql = local.sql, datasource = arguments.datasource); + return local.rv; + } + } + + if ( + StructKeyExists(arguments, "type") && + arguments.type eq "index" && + $get("adapterName") eq "SQLiteModel" + ) { + local.sql = " + SELECT + NULL AS TABLE_CAT, + NULL AS TABLE_SCHEM, + '#arguments.table#' AS TABLE_NAME, + CASE WHEN il.""unique"" = 0 THEN 1 ELSE 0 END AS NON_UNIQUE, + NULL AS INDEX_QUALIFIER, + il.name AS INDEX_NAME, + 'Other Index' AS TYPE, + ii.seqno + 1 AS ORDINAL_POSITION, + ii.name AS COLUMN_NAME, + 'A' AS ASC_OR_DESC, + 0 AS CARDINALITY, + 0 AS PAGES, + '' AS FILTER_CONDITION + FROM pragma_index_list('#arguments.table#') il + JOIN pragma_index_info(il.name) ii + + UNION ALL + + SELECT + NULL AS TABLE_CAT, + NULL AS TABLE_SCHEM, + '#arguments.table#' AS TABLE_NAME, + 0 AS NON_UNIQUE, + NULL AS INDEX_QUALIFIER, + 'PRIMARY' AS INDEX_NAME, + 'Primary Key' AS TYPE, + pk AS ORDINAL_POSITION, + name AS COLUMN_NAME, + 'A' AS ASC_OR_DESC, + 0 AS CARDINALITY, + 0 AS PAGES, + '' AS FILTER_CONDITION + FROM pragma_table_info('#arguments.table#') + WHERE pk > 0 + + ORDER BY INDEX_NAME, ORDINAL_POSITION; + "; + local.rv = $query(sql = local.sql, datasource = arguments.datasource); + return local.rv; + } + + // If the cfdbinfo call fails we try it again, this time setting "dbname" explicitly. + // Sometimes the call fails when using a custom database connection string. + // In that case the database name is not known by the CF server and it will just use any of the databases that the data source has access to. + // That can incorrectly be "information_schema" for example. + try { + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cfdbinfo(attributeCollection = local.args); + } catch (any e) { + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cfdbinfo(attributeCollection = local.args); + local.type = arguments.type; + arguments.type = "dbnames"; + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cfdbinfo(attributeCollection = local.args); + if (local.rv.recordCount GT 1) { + for (local.i in local.rv) { + if (local.i.database_name IS NOT "information_schema") { + arguments.dbname = local.i.database_name; + } + } + } + arguments.type = local.type; + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cfdbinfo(attributeCollection = local.args); + } + + // Override name for test mode + if ( + arguments.type IS "version" AND + StructKeyExists(url, "controller") AND + StructKeyExists(url, "action") AND + StructKeyExists(url, "view") AND + StructKeyExists(url, "type") AND + StructKeyExists(url, "adapter") + ) { + if (url.controller IS "wheels" AND url.action IS "wheels" AND url.view IS "tests" AND url.type IS "core") { + QuerySetCell(local.rv, "driver_name", url.adapter); + } + } + + return local.rv; + } + + + /** + * Drops rows belonging to a database's system schemas from a `$dbinfo(type="columns")` + * result. + * + * `cfdbinfo(type="columns")` passes no schema restriction to JDBC's `getColumns()`, so the + * table name is matched across EVERY schema on the connection. PostgreSQL and YugabyteDB + * ship real ANSI `information_schema` views named `sequences`, `tables`, `columns`, + * `views`, `triggers` and more, so an application table sharing one of those names silently + * collects a second batch of phantom columns from the catalog (issue #3349). No application + * table lives in a system schema, so filtering them out is always safe. + * + * A result set that carries no `table_schem` column — several engines omit it — is returned + * untouched. Yes, JDBC really does spell it `table_schem`, not `table_schema`. + */ + public query function $excludeSystemSchemaRows( + required query columns, + string schemas = "information_schema,pg_catalog,crdb_internal,pg_extension" + ) { + if (!ListFindNoCase(arguments.columns.columnList, "table_schem")) { + return arguments.columns; + } + local.rv = QueryNew(arguments.columns.columnList); + local.columnNames = ListToArray(arguments.columns.columnList); + local.iEnd = arguments.columns.recordCount; + for (local.i = 1; local.i <= local.iEnd; local.i++) { + if (!ListFindNoCase(arguments.schemas, arguments.columns["table_schem"][local.i])) { + QueryAddRow(local.rv); + local.jEnd = ArrayLen(local.columnNames); + for (local.j = 1; local.j <= local.jEnd; local.j++) { + local.item = local.columnNames[local.j]; + QuerySetCell(local.rv, local.item, arguments.columns[local.item][local.i]); + } + } + } + return local.rv; + } + + + public any function $wddx(required any input, string action = "cfml2wddx", boolean useTimeZoneInfo = true) { + arguments.output = "local.output"; + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cfwddx(attributeCollection = "#local.args#"); + if (StructKeyExists(local, "output")) { + return local.output; + } + } + + + public any function $zip() { + $engineAdapter().prepareZipArgs(arguments); + local.args = {}; + for (local.key in arguments) { + local.args[local.key] = arguments[local.key]; + } + cfzip(attributeCollection = "#local.args#"); + } + + + public any function $query(required string sql) { + StructDelete(arguments, "name"); + // allow the use of query of queries, caveat: Query must be called query. Eg: SELECT * from query + if (StructKeyExists(arguments, "query") && IsQuery(arguments.query)) { + var query = Duplicate(arguments.query); + } + local.rv = QueryExecute(PreserveSingleQuotes(arguments.sql), [], arguments); + // some sql statements may not return a value + if (StructKeyExists(local, "rv")) { + return local.rv; + } + } + diff --git a/vendor/wheels/global/util.cfm b/vendor/wheels/global/util.cfm new file mode 100644 index 0000000000..ed38e4c9ec --- /dev/null +++ b/vendor/wheels/global/util.cfm @@ -0,0 +1,675 @@ + +/** + * wheels.Global include: util + * List/struct/args helpers, XML, obfuscation, MIME, UUID. + * + * Included from `vendor/wheels/Global.cfc` at component-body scope so + * these functions compile into the Global component. Children inherit + * them; there is no per-instance mixin copy. Keep every helper that + * must mix onto models/controllers `public` and `$`-prefixed + * (cross-engine invariant 7). + */ + + + // ====================================================================== + // PARAMS FUNCTIONS + // ====================================================================== + + /** + * Internal function. + */ + public any function $cleanInlist(required string where) { + local.rv = arguments.where; + local.regex = "IN\s?\(.*?,?\s?.*?\)"; + local.in = ReFind(local.regex, local.rv, 1, true); + while (local.in.len[1]) { + local.str = Mid(local.rv, local.in.pos[1], local.in.len[1]); + local.rv = RemoveChars(local.rv, local.in.pos[1], local.in.len[1]); + local.cleaned = $listClean(local.str); + local.rv = Insert(local.cleaned, local.rv, local.in.pos[1] - 1); + local.in = ReFind(local.regex, local.rv, local.in.pos[1] + Len(local.cleaned), true); + } + return local.rv; + } + + + /** + * Removes whitespace between list elements. + * Optional argument to return the list as an array. + */ + public any function $listClean(required string list, string delim = ",", string returnAs = "string") { + local.rv = ListToArray(arguments.list, arguments.delim); + local.iEnd = ArrayLen(local.rv); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.rv[local.i] = Trim(local.rv[local.i]); + } + if (arguments.returnAs != "array") { + local.rv = ArrayToList(local.rv, arguments.delim); + } + return local.rv; + } + + + /** + * Converts a comma delimted list to a struct + */ + public struct function $listToStruct(required string list, string value = 1) { + local.rv = {}; + local.cleanList = $listClean(list = arguments.list, returnAs = "array"); + for (local.key in local.cleanList) { + local.rv[local.key] = arguments.value; + } + return local.rv; + } + + + /** + * Internal function. Wheels's canonical plural-or-singular argument alias + * helper. If `args.` is set, copy it to `args.` and delete + * the original — so the function body can read `args.` uniformly + * regardless of which name the caller used. With `required=true`, throws + * `Wheels.IncorrectArguments` when neither name is provided. + * + * Canonical examples: + * - `combine = "columnNames,columnName"` — migrator column helpers in + * vendor/wheels/migrator/TableDefinition.cfc + * - `combine = "properties,property"` — model validations in + * vendor/wheels/model/validations.cfc + * - `combine = "formats,format"` — controller provides() in + * vendor/wheels/controller/provides.cfc + * - `combine = "referenceNames,columnNames"` — t.references() per #2781 + * + * When adding a new helper that takes a list-or-single argument, follow + * this pattern: declare the plural form on the signature (NOT required), + * then call $combineArguments(required=true) at the top of the body so the + * alias works AND the required-ness is enforced at runtime. + */ + public void function $combineArguments( + required struct args, + required string combine, + required boolean required = false, + string extendedInfo = "" + ) { + local.first = ListGetAt(arguments.combine, 1); + local.second = ListGetAt(arguments.combine, 2); + if (StructKeyExists(arguments.args, local.second)) { + arguments.args[local.first] = arguments.args[local.second]; + StructDelete(arguments.args, local.second); + } + if (arguments.required && application.wheels.showErrorInformation) { + if (!StructKeyExists(arguments.args, local.first) || !Len(arguments.args[local.first])) { + Throw( + type = "Wheels.IncorrectArguments", + message = "The `#local.second#` or `#local.first#` argument is required but was not passed in.", + extendedInfo = "#arguments.extendedInfo#" + ); + } + } + } + + + + /** + * Check to see if all keys in the list exist for the structure and have length. + */ + public boolean function $structKeysExist(required struct struct, string keys = "") { + local.rv = true; + local.keyArray = ListToArray(arguments.keys); + local.iEnd = ArrayLen(local.keyArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.key = local.keyArray[local.i]; + if ( + !StructKeyExists(arguments.struct, local.key) + || ( + IsSimpleValue(arguments.struct[local.key]) + && !Len(arguments.struct[local.key]) + ) + ) { + local.rv = false; + break; + } + } + return local.rv; + } + + + /** + * Creates a struct of the named arguments passed in to a function (i.e. the ones not explicitly defined in the arguments list). + * + * @defined List of already defined arguments that should not be added. + */ + public struct function $namedArguments(required string $defined) { + local.rv = {}; + for (local.key in arguments) { + if (!ListFindNoCase(arguments.$defined, local.key) && Left(local.key, 1) != "$") { + local.rv[local.key] = arguments[local.key]; + } + } + return local.rv; + } + + + /** + * Internal function. + */ + public struct function $dollarify(required struct input, required string on) { + for (local.key in arguments.input) { + if (ListFindNoCase(arguments.on, local.key)) { + arguments.input["$" & local.key] = arguments.input[local.key]; + StructDelete(arguments.input, local.key); + } + } + return arguments.input; + } + + + /** + * Internal function. + */ + public void function $args( + required struct args, + required string name, + string reserved = "", + string combine = "", + string required = "" + ) { + if (Len(arguments.combine)) { + local.combineKeysArray = ListToArray(arguments.combine); + local.iEnd = ArrayLen(local.combineKeysArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.item = local.combineKeysArray[local.i]; + local.first = ListGetAt(local.item, 1, "/"); + local.second = ListGetAt(local.item, 2, "/"); + local.required = false; + if (ListLen(local.item, "/") > 2 || ListFindNoCase(local.first, arguments.required)) { + local.required = true; + } + $combineArguments(args = arguments.args, combine = "#local.first#,#local.second#", required = local.required); + } + } + if (application.wheels.showErrorInformation) { + if (ListLen(arguments.reserved)) { + local.iEnd = ListLen(arguments.reserved); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.item = ListGetAt(arguments.reserved, local.i); + if (StructKeyExists(arguments.args, local.item)) { + Throw( + type = "Wheels.IncorrectArguments", + message = "The `#local.item#` argument cannot be passed in since it will be set automatically by Wheels." + ); + } + } + } + } + if (StructKeyExists(application.wheels.functions, arguments.name)) { + $engineAdapter().structAppendDefaults(arguments.args, application.wheels.functions[arguments.name]); + } + + // make sure that the arguments marked as required exist + if (Len(arguments.required)) { + local.requiredKeysArray = ListToArray(arguments.required); + local.iEnd = ArrayLen(local.requiredKeysArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.arg = local.requiredKeysArray[local.i]; + if (!StructKeyExists(arguments.args, local.arg)) { + Throw( + type = "Wheels.IncorrectArguments", + message = "The `#local.arg#` argument is required but not passed in." + ); + } + } + } + } + + + // ====================================================================== + // MISC FUNCTIONS + // ====================================================================== + + /** + * Call CFML's canonicalize() function but set to blank string if the result is null (happens on Lucee 5). + */ + public string function $canonicalize(required string input) { + try { + local.rv = Canonicalize(arguments.input, false, false); + if (IsNull(local.rv)) { + local.rv = ""; + } + } catch (any e) { + // Lucee's Canonicalize() delegates to Java's URLDecoder, which throws + // IllegalArgumentException for inputs containing malformed percent-encoded + // sequences (e.g. %% or a lone % not followed by two hex digits). + // Fall back to the raw input; it will still be HTML-encoded by the caller. + local.rv = arguments.input; + } + return local.rv; + } + + + /** + * Internal function. + * Disambiguates a D1/D2/YYYY slash date: a component greater than 12 cannot + * be a month so the format is unambiguous; otherwise the engine adapter's + * locale preference decides (MM/DD/YYYY on Lucee / Adobe, DD/MM/YYYY on + * BoxLang). All slash-date parsing should funnel through this helper. + */ + public date function $parseSlashDate(required numeric d1, required numeric d2, required numeric year) { + if (arguments.d1 > 12) { + // the first component cannot be a month so it must be the day (DD/MM/YYYY) + return CreateDate(arguments.year, arguments.d2, arguments.d1); + } else if (arguments.d2 > 12) { + // the second component cannot be a month so it must be the day (MM/DD/YYYY) + return CreateDate(arguments.year, arguments.d1, arguments.d2); + } else { + return $engineAdapter().parseAmbiguousSlashDate(arguments.d1, arguments.d2, arguments.year); + } + } + + + /** + * Internal function. + */ + public string function $convertToString(required any value, string type = "") { + // Normalize inputs + local.val = arguments.value; + local.detectedType = arguments.type; + + // Coerce Oracle JDBC objects (TIMESTAMP, DATE) to CFML datetime values. + if (IsObject(local.val)) { + local.coerced = $engineAdapter().coerceOracleObject(local.val); + if (!IsObject(local.coerced) || local.coerced.hashCode() != local.val.hashCode()) { + local.val = local.coerced; + if (IsDate(local.val)) { + local.detectedType = "datetime"; + } else { + local.detectedType = "string"; + } + } + } + + // If no explicit type passed, try to detect a sensible one + if (!Len(detectedType)) { + if (IsArray(val)) { + detectedType = "array"; + } else if (IsStruct(val)) { + detectedType = "struct"; + } else if (IsBinary(val)) { + detectedType = "binary"; + } else if (IsNumeric(val)) { + detectedType = "integer"; + } else if (IsDate(val)) { + detectedType = "datetime"; + } else { + detectedType = "string"; + } + } + + // --- EARLY DATE/TIME PROMOTION --- + // If the caller provided a non-datetime type (eg "string") but the value looks like a date/time, + // promote it to datetime so the switch branch will canonicalize properly. + if ( + detectedType NEQ "datetime" + AND IsSimpleValue(val) + AND Len(Trim(val)) + ) { + local.s = Trim(val); + + // Match patterns loosely so they work for plain dates too + local.patternAMPM = '^\d{1,2}/\d{1,2}/\d{4}(\s+\d{1,2}:\d{2}(\s*(AM|PM))?)?$'; + local.patternISO = '^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?$'; + local.patternSlash = '^\s*\d{1,2}/\d{1,2}/\d{4}\s*$'; + + + // Day name or other verbose formats are ignored to avoid false positives + if ( + ReFindNoCase(local.patternAMPM, local.s) OR ReFindNoCase(local.patternISO, local.s) OR ReFindNoCase( + local.patternSlash, + local.s + ) + ) { + // Promote to datetime so the datetime branch will run below + detectedType = "datetime"; + } + } + + // Pre-process date strings with AM/PM that may be parsed differently per engine + if ( + $engineAdapter().isBoxLang() && IsSimpleValue(arguments.value) && ReFindNoCase( + "^\d{1,2}/\d{1,2}/\d{4} \d{1,2}:\d{2} (AM|PM)$", + arguments.value + ) + ) { + // Manually parse the slash date to avoid engine-specific interpretation, + // disambiguating day/month through $parseSlashDate() + local.parts = ListToArray(arguments.value, " "); + local.datePart = local.parts[1]; + local.timePart = local.parts[2]; + local.amPm = local.parts[3]; + + local.dateComponents = ListToArray(local.datePart, "/"); + local.timeComponents = ListToArray(local.timePart, ":"); + + local.parsedDate = $parseSlashDate( + d1 = Val(local.dateComponents[1]), + d2 = Val(local.dateComponents[2]), + year = Val(local.dateComponents[3]) + ); + local.hour = Val(local.timeComponents[1]); + local.minute = Val(local.timeComponents[2]); + + if (local.amPm == "PM" && local.hour != 12) { + local.hour += 12; + } else if (local.amPm == "AM" && local.hour == 12) { + local.hour = 0; + } + val = CreateDateTime( + Year(local.parsedDate), + Month(local.parsedDate), + Day(local.parsedDate), + local.hour, + local.minute, + 0 + ); + detectedType = "datetime"; + } + + // --- SWITCH ON (possibly promoted) TYPE --- + switch (detectedType) { + case "array": + return ArrayToList(val); + case "struct": + local.kList = ListSort(StructKeyList(val), "textnocase", "asc"); + local.out = ""; + for (local.k in ListToArray(local.kList)) { + local.out = ListAppend(local.out, local.k & "=" & val[local.k]); + } + return local.out; + case "binary": + return ToString(val); + case "float": + case "integer": + if (!Len(val)) { + return ""; + } + if (val == "true") { + return "1"; + } + return Val(val); + case "boolean": + if (Len(val)) { + return (val IS true) ? "true" : "false"; + } + return ""; + case "datetime": + // If it's already a date object, canonicalize + if (IsDate(val)) { + return DateFormat(val, "yyyy-mm-dd") & " " & TimeFormat(val, "HH:mm:ss"); + } + + // If it is a string that looks like a date, try parsing + if (IsSimpleValue(val)) { + local.s2 = Trim(val); + // Try ParseDateTime (which handles many formats) + try { + local.dt = ParseDateTime(local.s2); + if (IsDate(local.dt)) { + return DateFormat(local.dt, "yyyy-mm-dd") & " " & TimeFormat(local.dt, "HH:mm:ss"); + } + } catch (any e) { + // fallback parsing attempts for common formats + + // 1) ISO YYYY-MM-DD[ hh[:mm[:ss]]] + // Single-backslash escapes: in CFML "\\d" is a literal + // backslash + d in the compiled regex, which never matches a + // digit — the branch was dead. Mirrors the already-fixed + // slash-format branch below (#2933 carry-forward, #2977). + if (ReFind("(?i)^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$", local.s2)) { + local.parts = ReReplace(local.s2, "^(\d{4})-(\d{2})-(\d{2}).*$", "\1-\2-\3", "all"); + local.timePart = ReReplace(local.s2, ".*[ T](\d{1,2}:\d{2}(?::\d{2})?).*$", "\1", "all"); + if (Len(local.timePart) AND local.timePart NEQ local.s2) { + // has time + local.dt = ParseDateTime(local.parts & " " & local.timePart); + if (IsDate(local.dt)) { + return DateFormat(local.dt, "yyyy-mm-dd") & " " & TimeFormat(local.dt, "HH:mm:ss"); + } + } else { + // date only + local.dt = CreateDate( + Val(ListGetAt(local.parts, 1, "-")), + Val(ListGetAt(local.parts, 2, "-")), + Val(ListGetAt(local.parts, 3, "-")) + ); + return DateFormat(local.dt, "yyyy-mm-dd") & " 00:00:00"; + } + } + + // 2) Slash format DD/MM/YYYY or MM/DD/YYYY — disambiguated by $parseSlashDate() + if (ReFind("^\d{1,2}/\d{1,2}/\d{4}", local.s2)) { + local.comps = ListToArray(local.s2, "/"); + local.dt = $parseSlashDate( + d1 = Val(local.comps[1]), + d2 = Val(local.comps[2]), + year = Val(local.comps[3]) + ); + // if time exists in same string, try to parse it using ParseDateTime + if (ReFind("\d{1,2}:\d{2}", local.s2)) { + try { + local.dt2 = ParseDateTime(local.s2); + if (IsDate(local.dt2)) { + return DateFormat(local.dt2, "yyyy-mm-dd") & " " & TimeFormat(local.dt2, "HH:mm:ss"); + } + } catch (any e2) { + // fallback to midnight + return DateFormat(local.dt, "yyyy-mm-dd") & " 00:00:00"; + } + } + return DateFormat(local.dt, "yyyy-mm-dd") & " 00:00:00"; + } + } + } + // If we reach here, parsing failed — return original string to allow comparison + return val; + default: + // Default: return raw value as string (no conversion) + return val; + } + } + + + /** + * Internal function. + */ + public xml function $toXml(required any data) { + // only instantiate the toXml object once per request + if (!StructKeyExists(request.wheels, "toXml")) { + request.wheels.toXml = $createObjectFromRoot( + path = "#application.wheels.wheelsComponentPath#.vendor.toXml", + fileName = "toXML", + method = "init" + ); + } + + return request.wheels.toXml.toXml(arguments.data); + } + + + /** + * Obfuscates a value. Typically used for hiding primary key values when passed along in the URL. + * + * [section: Global Helpers] + * [category: Miscellaneous Functions] + * + * @param The value to obfuscate. + */ + public string function obfuscateParam(required any param) { + local.rv = arguments.param; + local.param = ArrayToList(ReMatch("[0-9]+", arguments.param), ""); + if (Len(local.param) && local.param > 0 && Left(local.param, 1) != 0) { + local.iEnd = Len(local.param); + local.a = (10^local.iEnd) + Reverse(local.param); + local.b = 0; + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.b += Left(Right(local.param, local.i), 1); + } + if (IsValid("integer", local.a)) { + local.rv = FormatBaseN(local.b + 154, 16) & FormatBaseN(BitXor(local.a, 461), 16); + } + } + return local.rv; + } + + + /** + * Deobfuscates a value. + * + * [section: Global Helpers] + * [category: Miscellaneous Functions] + * + * @param The value to deobfuscate. + */ + public string function deobfuscateParam(required string param) { + if (Val(arguments.param) != arguments.param) { + try { + local.checksum = Left(arguments.param, 2); + local.rv = Right(arguments.param, Len(arguments.param) - 2); + local.z = BitXor(InputBaseN(local.rv, 16), 461); + local.rv = ""; + local.iEnd = Len(local.z) - 1; + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.rv &= Left(Right(local.z, local.i), 1); + } + local.checkSumTest = 0; + local.iEnd = Len(local.rv); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.checkSumTest += Left(Right(local.rv, local.i), 1); + } + local.c1 = ToString(FormatBaseN(local.checkSumTest + 154, 10)); + local.c2 = InputBaseN(local.checksum, 16); + if (local.c1 != local.c2) { + local.rv = arguments.param; + } + } catch (any e) { + local.rv = arguments.param; + } + } else { + local.rv = arguments.param; + } + return local.rv; + } + + + /** + * Returns an associated MIME type based on a file extension. + * + * [section: Global Helpers] + * [category: Miscellaneous Functions] + * + * @extension The extension to get the MIME type for. + * @fallback The fallback MIME type to return. + */ + public string function mimeTypes(required string extension, string fallback = "application/octet-stream") { + local.rv = arguments.fallback; + if (StructKeyExists(application.wheels.mimetypes, arguments.extension)) { + local.rv = application.wheels.mimetypes[arguments.extension]; + } + return local.rv; + } + + + /** + * Adds a new MIME type to your Wheels application for use with responding to multiple formats. + * + * [section: Configuration] + * [category: Miscellaneous Functions] + * + * @extension File extension to add. + * @mimeType Matching MIME type to associate with the file extension. + */ + public void function addFormat(required string extension, required string mimeType) { + local.appKey = $appKey(); + application[local.appKey].formats[arguments.extension] = arguments.mimeType; + } + + + /** + * Internal function. + */ + public string function $appKey() { + local.rv = "wheels"; + if (StructKeyExists(application, "$wheels")) { + local.rv = "$wheels"; + } + return local.rv; + } + + + /** + * Generates a 36-character UUID compatible with SQL Server's uniqueidentifier. + * + * [section: Global Helpers] + * [category: UUID Functions] + * + * @return A valid 36-character UUID string (e.g., 123e4567-e89b-12d3-a456-426614174000) + */ + public string function generateUUID() { + // Use Java UUID generator for a 36-character format + return CreateObject("java", "java.util.UUID").randomUUID().toString(); + } + + + public array function $splitOutsideFunctions(required string list, required string splitBy) { + local.rv = []; + local.temp = ""; + local.insideFunction = false; + local.bracketCount = 0; + + for (local.i = 1; i <= Len(arguments.list); i++) { + local.char = Mid(arguments.list, i, 1); + + // Check if we are entering or exiting a function's parentheses + if (local.char == "(") { + local.bracketCount++; + } else if (local.char == ")") { + local.bracketCount--; + } + + // Determine if we are inside a function (any content enclosed by parentheses) + if (local.bracketCount > 0) { + local.insideFunction = true; + } else if (local.bracketCount == 0) { + local.insideFunction = false; + } + + // Split based on commas outside functions + if (local.char == arguments.splitBy && !local.insideFunction) { + ArrayAppend(local.rv, Trim(local.temp)); + local.temp = ""; + } else { + local.temp &= local.char; + } + } + + // Append the final segment + if (Len(Trim(local.temp))) { + ArrayAppend(local.rv, Trim(local.temp)); + } + + return local.rv; + } + + + /** + * Normalizes a nested key path by converting bracket notation (e.g., `form[user][email]`) to dot notation (e.g., `form.user.email`). + * + * [section: Global Helpers] + * [category: String Functions] + * + * @path The key path to normalize. + */ + public string function $normalizePath(required string path) { + local.norm = arguments.path; + local.norm = ReReplace(local.norm, "\[(.*?)\]", ".\1", "all"); + local.norm = ReReplace(local.norm, "^\.", "", "one"); + return local.norm; + } + diff --git a/vendor/wheels/interfaces/StorageDiskInterface.cfc b/vendor/wheels/interfaces/StorageDiskInterface.cfc new file mode 100644 index 0000000000..22c0d16758 --- /dev/null +++ b/vendor/wheels/interfaces/StorageDiskInterface.cfc @@ -0,0 +1,67 @@ +/** + * Contract every storage disk driver must satisfy. + * + * A "disk" is a named, configured storage backend (local filesystem, S3, …). + * Drivers expose one small, uniform surface so application code can swap + * backends with a config change and no call-site edits — mirroring Laravel's + * Filesystem and Rails' ActiveStorage::Service abstractions. + * + * Implementations live under `wheels.storage.drivers.*`. Resolve a configured + * disk through `wheels.storage.StorageManager`. + * + * [section: Storage] + * [category: Interface] + */ +interface { + + /** + * Store content at the given key, creating intermediate paths as needed. + * + * @key The opaque storage key (path-like, forward-slash separated). + * @content Binary or string content to write. + * @contentType MIME type hint (used by cloud backends; ignored by local). + * @visibility "public" or "private"; backends that support ACLs honour it. + * @return The stored key. + */ + public any function put(required string key, required any content, string contentType, string visibility); + + /** + * Read the content stored at the given key as binary. + * + * @key The storage key. + * @return Binary content. Throws Wheels.Storage.NotFound when absent. + */ + public any function get(required string key); + + /** + * Whether an object exists at the given key. + * + * @key The storage key. + */ + public boolean function exists(required string key); + + /** + * Delete the object at the given key. + * + * @key The storage key. + * @return true when an object was deleted, false when nothing was there. + */ + public boolean function delete(required string key); + + /** + * A non-expiring URL for the object (public objects / served route). + * + * @key The storage key. + */ + public string function url(required string key); + + /** + * A signed, time-limited URL granting temporary access to a private object. + * + * @key The storage key. + * @expiresIn Seconds until the URL expires (default 300). + * @contentDisposition Optional Content-Disposition the download should carry. + */ + public string function signedUrl(required string key, numeric expiresIn, string contentDisposition); + +} diff --git a/vendor/wheels/interfaces/database/DatabaseMigratorAdapterInterface.cfc b/vendor/wheels/interfaces/database/DatabaseMigratorAdapterInterface.cfc index 890a8d571d..0b6e64db93 100644 --- a/vendor/wheels/interfaces/database/DatabaseMigratorAdapterInterface.cfc +++ b/vendor/wheels/interfaces/database/DatabaseMigratorAdapterInterface.cfc @@ -54,7 +54,7 @@ interface { * @allowNull Whether NULL is allowed. * @return True if a DEFAULT clause should be added. */ - public boolean function optionsIncludeDefault(string type, string default, boolean allowNull); + public boolean function optionsIncludeDefault(string type, default, boolean allowNull); /** * Quote a value for use in DDL statements. diff --git a/vendor/wheels/middleware/TenantResolver.cfc b/vendor/wheels/middleware/TenantResolver.cfc index 03a00fb218..950b50a2d7 100644 --- a/vendor/wheels/middleware/TenantResolver.cfc +++ b/vendor/wheels/middleware/TenantResolver.cfc @@ -45,11 +45,21 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { * Resolve the tenant, set request.wheels.tenant, then delegate to the next middleware. */ public string function handle(required struct request, required any next) { - // Note: In CFML, bare `request` inside a function always refers to the - // built-in request scope, even when a parameter is named `request`. - // We use `arguments.request` to access the middleware pipeline's request struct, - // but set tenant state on the built-in `request` scope since that's what - // $performQuery() and $get() read from. + // Note: this function has a parameter named `request` (the MiddlewareInterface + // signature mandates it), so the bare `request` token is ambiguous. On Lucee and + // Adobe 2023 it resolves to the built-in request scope; on Adobe 2025 it does NOT + // resolve consistently — the same token can mean the built-in scope in one + // expression position and `arguments.request` in another within this function. + // + // So: use `arguments.request` explicitly for the middleware pipeline's request + // struct, and touch the built-in scope (where $performQuery() and $get() read + // tenant state from) only through one of two self-consistent forms — + // * `IsDefined("request.wheels.tenant")` before reading or deleting, which + // string-resolves the whole path in a single evaluation, or + // * assign before use: `if (!StructKeyExists(request, "wheels")) { request.wheels = {}; }` + // Never guard with `StructKeyExists(request, ...)` and then access `request.x` — + // the guard passes and the access throws `Element WHEELS is undefined in REQUEST` + // on Adobe 2025 (cross-engine invariant 15). local.tenant = $resolveTenant(arguments.request); @@ -73,6 +83,19 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { // Set on the built-in request scope (where $performQuery reads it) request.wheels.tenant = local.tenant; + } else if (IsDefined("request.wheels.tenant")) { + // The resolver found no match. Drop any value already sitting on the key so a stale + // or foreign one can't outlive resolution and be read downstream as a resolved + // tenant — an unresolved request must look unresolved for the whole request (#3336). + // + // Guard with IsDefined on the full path, matching the finally block below. A + // `StructKeyExists(request, "wheels")` guard is NOT equivalent here: this function + // takes a parameter named `request`, and on Adobe 2025 the bare `request` token + // resolves differently between the StructKeyExists argument and the `request.wheels` + // member-access expression, so the guard passed and the delete then threw + // `Element WHEELS is undefined in REQUEST`. IsDefined resolves the whole dotted path + // in one evaluation, so it cannot disagree with itself. + StructDelete(request.wheels, "tenant"); } try { diff --git a/vendor/wheels/migrator/Base.cfc b/vendor/wheels/migrator/Base.cfc index 128b2611cf..408f385aa7 100644 --- a/vendor/wheels/migrator/Base.cfc +++ b/vendor/wheels/migrator/Base.cfc @@ -232,6 +232,14 @@ component extends="wheels.Global"{ type = "columns", table = arguments.tableName ); + // This path calls $dbinfo directly rather than going through the model adapter, so it + // needs its own catalog-bleed guard: an unrestricted cfdbinfo matches the table name in + // every schema on the connection, and a table named after an `information_schema` view + // (`sequences`, `tables`, `columns`, …) picks up that view's columns too. A + // changeTable() adding a column whose name collides with one of them — `data_type`, + // `start_value` — would then see it as already present (issue #3349). No application + // table lives in a system schema, so this cannot drop a real column. + local.columns = $excludeSystemSchemaRows(columns = local.columns); local.columnList = ValueList(local.columns.COLUMN_NAME); if (!StructKeyExists(request, "$wheelsMigratorColumns")) { request.$wheelsMigratorColumns = {}; diff --git a/vendor/wheels/migrator/CLAUDE.md b/vendor/wheels/migrator/CLAUDE.md index becbcb60dd..bcbfc080a7 100644 --- a/vendor/wheels/migrator/CLAUDE.md +++ b/vendor/wheels/migrator/CLAUDE.md @@ -48,7 +48,11 @@ New code should pass `columnNames`. Both keep working. | `false` (framework default) | `userid` | `userid`, `usertype` | | `true` (new-app template default) | `user_id` | `user_id`, `user_type` | -The framework default is `false` so existing apps with applied migrations keep matching their database schemas. The `wheels new` template at `cli/lucli/templates/app/config/settings.cfm` opts new apps into `true` so they match Wheels model `belongsTo` defaults out of the box. +The framework default is `false` so existing apps with applied migrations keep matching their database schemas. The `wheels new` template at `cli/lucli/templates/app/config/settings.cfm` opts new apps into `true`. + +**The model side does not read this flag, and must not.** Association foreign-key defaults resolve against the columns that actually exist — `vendor/wheels/model/sql.cfc::$deriveAssociationForeignKey()` tries the legacy `` shape first and falls back to `_` — so both conventions work, including a schema holding a mix of the two. Making it flag-driven instead would break: this function's result is memoized for the application lifetime (`expandedMetadataFilled`), whereas `references()` re-reads `$get()` on every call, so a runtime flip would change migrations without changing models. Before [#3337](https://github.com/wheels-dev/wheels/issues/3337) the model layer derived `` unconditionally, which meant a stock `wheels new` app had a migrator and a model layer that could never agree. + +The exception is **polymorphic** associations, which pin their foreign key to `id` at registration time — see the note in the root `CLAUDE.md`. Those still need an explicit `foreignKey=` under the underscore convention. The flag is read via `$get("useUnderscoreReferenceColumns")` inside `references()` at runtime — apps can flip the setting in `config/settings.cfm` without reloading the framework. Migrations already applied to a real database are unaffected; only the column name the *next* migration produces changes. diff --git a/vendor/wheels/migrator/Migration.cfc b/vendor/wheels/migrator/Migration.cfc index 6121f0ab85..c79be481e2 100755 --- a/vendor/wheels/migrator/Migration.cfc +++ b/vendor/wheels/migrator/Migration.cfc @@ -170,7 +170,7 @@ component extends="Base" { string columnNames, string afterColumn = "", string referenceName = "", - string default, + default, boolean allowNull, numeric limit, numeric precision, @@ -213,7 +213,7 @@ component extends="Base" { required string columnType, string afterColumn = "", string referenceName = "", - string default, + default, boolean allowNull, numeric limit, numeric precision, diff --git a/vendor/wheels/migrator/TableDefinition.cfc b/vendor/wheels/migrator/TableDefinition.cfc index b58f3fb56a..bce07c1b64 100644 --- a/vendor/wheels/migrator/TableDefinition.cfc +++ b/vendor/wheels/migrator/TableDefinition.cfc @@ -96,7 +96,7 @@ component extends="Base" { public any function column( required string columnName, required string columnType, - string default, + default, boolean allowNull, any limit, numeric precision, @@ -148,7 +148,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function bigInteger(string columnNames, numeric limit, string default, boolean allowNull) { + public any function bigInteger(string columnNames, numeric limit, default, boolean allowNull) { return $addTypedColumns(columnType = "biginteger", args = arguments); } @@ -158,7 +158,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function binary(string columnNames, string default, boolean allowNull) { + public any function binary(string columnNames, default, boolean allowNull) { return $addTypedColumns(columnType = "binary", args = arguments); } @@ -168,7 +168,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function boolean(string columnNames, string default, boolean allowNull) { + public any function boolean(string columnNames, default, boolean allowNull) { return $addTypedColumns(columnType = "boolean", args = arguments); } @@ -178,7 +178,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function date(string columnNames, string default, boolean allowNull) { + public any function date(string columnNames, default, boolean allowNull) { return $addTypedColumns(columnType = "date", args = arguments); } @@ -188,7 +188,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function datetime(string columnNames, string default, boolean allowNull) { + public any function datetime(string columnNames, default, boolean allowNull) { return $addTypedColumns(columnType = "datetime", args = arguments); } @@ -198,7 +198,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function decimal(string columnNames, string default, boolean allowNull, numeric precision, numeric scale) { + public any function decimal(string columnNames, default, boolean allowNull, numeric precision, numeric scale) { return $addTypedColumns(columnType = "decimal", args = arguments); } @@ -208,7 +208,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function float(string columnNames, string default = "", boolean allowNull = "true") { + public any function float(string columnNames, default = "", boolean allowNull = "true") { // NOTE: the default=""/allowNull="true" parameter defaults are a // long-standing outlier among these helpers — preserved as-is for // backward compatibility (addColumnOptions renders default="" as @@ -222,7 +222,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function integer(string columnNames, numeric limit, string default, boolean allowNull) { + public any function integer(string columnNames, numeric limit, default, boolean allowNull) { return $addTypedColumns(columnType = "integer", args = arguments); } @@ -232,7 +232,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function string(string columnNames, any limit, string default, boolean allowNull) { + public any function string(string columnNames, any limit, default, boolean allowNull) { return $addTypedColumns(columnType = "string", args = arguments); } @@ -242,7 +242,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function char(string columnNames, any limit, string default, boolean allowNull) { + public any function char(string columnNames, any limit, default, boolean allowNull) { return $addTypedColumns(columnType = "char", args = arguments); } @@ -259,7 +259,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function text(string columnNames, string default, boolean allowNull, string size) { + public any function text(string columnNames, default, boolean allowNull, string size) { return $addTypedColumns(columnType = "text", args = arguments); } @@ -269,7 +269,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function uniqueidentifier(string columnNames, string default = "newid()", boolean allowNull) { + public any function uniqueidentifier(string columnNames, default = "newid()", boolean allowNull) { // NOTE: the default="newid()" parameter default is MSSQL syntax — this // helper is only registered by the MicrosoftSQLServer adapter, so the // outlier default is preserved as-is. @@ -282,7 +282,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function time(string columnNames, string default, boolean allowNull) { + public any function time(string columnNames, default, boolean allowNull) { return $addTypedColumns(columnType = "time", args = arguments); } @@ -292,7 +292,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function timestamp(string columnNames, string default, boolean allowNull, string columnType = "datetime") { + public any function timestamp(string columnNames, default, boolean allowNull, string columnType = "datetime") { // columnType is caller-overridable here (defaults to "datetime") — // unlike the sibling helpers, which stamp a fixed type. return $addTypedColumns(columnType = arguments.columnType, args = arguments); @@ -338,7 +338,7 @@ component extends="Base" { public any function references( string referenceNames, string columnNames, - string default, + default, boolean allowNull = "false", boolean polymorphic = "false", boolean foreignKey = "true", diff --git a/vendor/wheels/model/miscellaneous.cfc b/vendor/wheels/model/miscellaneous.cfc index 5e3af0f377..9a6b325bc6 100644 --- a/vendor/wheels/model/miscellaneous.cfc +++ b/vendor/wheels/model/miscellaneous.cfc @@ -1,9 +1,31 @@ component { + /** + * Internal function. + * Creates this model's slot in the per-request query cache if it doesn't exist yet. + * + * The cache is namespaced under the reserved `$queryCache` key rather than sitting directly in + * `request.wheels` under the bare model name. CFML struct keys are case-insensitive, so the flat + * layout let a model name alias onto a framework-owned request key — a model named `Tenant` + * shared one key with `request.wheels.tenant`, silently dropping tenant datasource routing (#3336). + */ + public void function $ensureRequestQueryCache() { + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + } + if (!StructKeyExists(request.wheels, "$queryCache")) { + request.wheels["$queryCache"] = {}; + } + if (!StructKeyExists(request.wheels["$queryCache"], variables.wheels.class.modelName)) { + request.wheels["$queryCache"][variables.wheels.class.modelName] = {}; + } + } + /** * Deletes all queries stored during the request for this model. */ public void function $clearRequestCache() { - request.wheels[variables.wheels.class.modelName] = {}; + $ensureRequestQueryCache(); + request.wheels["$queryCache"][variables.wheels.class.modelName] = {}; } /** diff --git a/vendor/wheels/model/onmissingmethod.cfc b/vendor/wheels/model/onmissingmethod.cfc index 3249d02dfe..cfeb450581 100644 --- a/vendor/wheels/model/onmissingmethod.cfc +++ b/vendor/wheels/model/onmissingmethod.cfc @@ -52,8 +52,13 @@ component { } // --- Chainable Query Builder entry points --- - // Allow calling .where(), .orWhere(), .orderBy() etc. directly on a model to start a query builder chain. - if (ListFindNoCase("where,orWhere,whereNull,whereNotNull,whereBetween,whereIn,whereNotIn,orderBy,limit,offset", arguments.missingMethodName)) { + // Allow calling .where(), .select(), .orderBy() etc. directly on a model to start a query builder chain. + // Note: user-defined scopes and enum checkers above take precedence, and a real model method with one of + // these names bypasses onMissingMethod entirely. The dynamic-finder and association-method branches below + // run AFTER this list, so an association named e.g. "select" resolves to the builder instead. Keep this + // list in sync with the scope-to-builder transition list in wheels.model.query.ScopeChain (where user + // scopes are checked first). + if (ListFindNoCase("where,orWhere,whereNull,whereNotNull,whereBetween,whereIn,whereNotIn,orderBy,limit,offset,select,include,group,distinct,forUpdate", arguments.missingMethodName)) { local.builder = new wheels.model.query.QueryBuilder(modelReference = this); // Delegate the call to the query builder return Invoke(local.builder, arguments.missingMethodName, arguments.missingMethodArguments); diff --git a/vendor/wheels/model/query/ScopeChain.cfc b/vendor/wheels/model/query/ScopeChain.cfc index 37169e9303..ccc7661447 100644 --- a/vendor/wheels/model/query/ScopeChain.cfc +++ b/vendor/wheels/model/query/ScopeChain.cfc @@ -233,8 +233,10 @@ component output="false" { return this; } - // Check if this is a QueryBuilder method — transition from scope chain to query builder - if (ListFindNoCase("where,orWhere,whereNull,whereNotNull,whereBetween,whereIn,whereNotIn,orderBy,limit,offset,select,include,group,distinct", arguments.missingMethodName)) { + // Check if this is a QueryBuilder method — transition from scope chain to query builder. + // User-defined scopes are checked BEFORE this list (above), so a scope named e.g. "select" keeps + // precedence. Keep this list in sync with the chain-entry list in wheels.model.onmissingmethod. + if (ListFindNoCase("where,orWhere,whereNull,whereNotNull,whereBetween,whereIn,whereNotIn,orderBy,limit,offset,select,include,group,distinct,forUpdate", arguments.missingMethodName)) { local.builder = new wheels.model.query.QueryBuilder(modelReference = variables.modelReference, scopeSpecs = variables.specs); return Invoke(local.builder, arguments.missingMethodName, arguments.missingMethodArguments); } diff --git a/vendor/wheels/model/read.cfc b/vendor/wheels/model/read.cfc index 6264f25346..e51cf7d116 100644 --- a/vendor/wheels/model/read.cfc +++ b/vendor/wheels/model/read.cfc @@ -10,6 +10,7 @@ component { * @order Maps to the `ORDER` BY clause of the query. You do not need to specify the table name(s); Wheels will do that for you. * @group Maps to the `GROUP BY` clause of the query. You do not need to specify the table name(s); Wheels will do that for you. * @select Determines how the `SELECT` clause for the query used to return data will look. You can pass in a list of the properties (which map to columns) that you want returned from your table(s). If you don't set this argument at all, Wheels will select all properties from your table(s). If you specify a table name (e.g. `users.email`) or alias a column (e.g. `fn AS firstName`) in the list, then the entire list will be passed through unchanged and used in the `SELECT` clause of the query. By default, all column names in tables joined via the `include` argument will be prepended with the singular version of the included table name. + * @includeCalculated List of calculated property names (declared via `property(name="...", sql="...", select=false)`) to additively opt into this finder's `SELECT` clause. Unlike `select`, this does not replace the default column list — the named calculated properties are merged on top of all default columns, so the rest of the record is still returned. Useful for pulling a `select=false` computed property back in on a single finder without spelling out every other column. Unknown names throw `Wheels.CalculatedPropertyNotFound` in `development`/`testing` and are ignored in `production`. * @distinct Whether to add the `DISTINCT` keyword to your `SELECT` clause. Wheels will, when necessary, add this automatically (when using pagination and a `hasMany` association is used in the `include` argument, to name one example). * @include Associations that should be included in the query using `INNER` or `LEFT OUTER` joins (which join type that is used depends on how the association has been set up in your model). If all included associations are set on the current model, you can specify them in a list (e.g. `department,addresses,emails`). You can build more complex include strings by using parentheses when the association is set on an included model, like `album(artist(genre))`, for example. These complex `include` strings only work when `returnAs` is set to `query` though. * @maxRows Maximum number of records to retrieve. Passed on to the `maxRows` `cfquery` attribute. The default, `-1`, means that all records will be retrieved. @@ -32,6 +33,7 @@ component { string order, string group, string select = "", + string includeCalculated = "", boolean distinct = "false", string include = "", numeric maxRows = "-1", @@ -214,7 +216,8 @@ component { include = arguments.include, includeSoftDeletes = arguments.includeSoftDeletes, list = arguments.select, - returnAs = arguments.returnAs + returnAs = arguments.returnAs, + includeCalculated = arguments.includeCalculated ); // Strip dialect quotes: $createSQLFieldList now quotes identifiers; the bare-identifier regex below requires unquoted input. local.columns = variables.wheels.class.adapter.$stripIdentifierQuotes(local.columns); @@ -247,7 +250,8 @@ component { select = arguments.select, include = arguments.include, includeSoftDeletes = arguments.includeSoftDeletes, - returnAs = arguments.returnAs + returnAs = arguments.returnAs, + includeCalculated = arguments.includeCalculated ) ); ArrayAppend( @@ -306,10 +310,8 @@ component { // Batch finders (findEach / findInBatches) opt out via $useRequestCache so their per-page results don't accumulate in the request scope for the remainder of the request. local.useRequestCache = application.wheels.cacheQueriesDuringRequest && arguments.$useRequestCache; if (local.useRequestCache) { - // Create a struct in the request scope to store cached queries. - if (!StructKeyExists(request.wheels, variables.wheels.class.modelName)) { - request.wheels[variables.wheels.class.modelName] = {}; - } + // Create this model's slot in the request-scoped query cache namespace. + $ensureRequestQueryCache(); // Derive the request cache key from the SQL shell key computed above (it already encodes the model name and the full arguments struct) so we don't have to serialize all arguments a second time. local.queryKey = $hashedKey(local.queryShellKey, local.originalWhere); @@ -319,9 +321,9 @@ component { if ( local.useRequestCache && !arguments.reload - && StructKeyExists(request.wheels[variables.wheels.class.modelName], local.queryKey) + && StructKeyExists(request.wheels["$queryCache"][variables.wheels.class.modelName], local.queryKey) ) { - local.findAll = request.wheels[variables.wheels.class.modelName][local.queryKey]; + local.findAll = request.wheels["$queryCache"][variables.wheels.class.modelName][local.queryKey]; } else { local.finderArgs = {}; local.finderArgs.sql = local.sql; @@ -353,7 +355,7 @@ component { local.findAll = variables.wheels.class.adapter.$querySetup(argumentCollection = local.finderArgs); if (local.useRequestCache) { // Store in request cache so we never run the exact same query twice in the same request. - request.wheels[variables.wheels.class.modelName][local.queryKey] = local.findAll; + request.wheels["$queryCache"][variables.wheels.class.modelName][local.queryKey] = local.findAll; } } @@ -407,6 +409,7 @@ component { * * @key Primary key value(s) of the record. Separate with comma if passing in multiple primary key values. Accepts a string, list, or a numeric value. * @select [see:findAll]. + * @includeCalculated [see:findAll]. * @include [see:findAll]. * @handle Handle to use for the query. This is used to set the name of the query in the debug output (which otherwise defaults to `userFindOneQuery` for example). * @cache [see:findAll]. @@ -420,6 +423,7 @@ component { public any function findByKey( required any key, string select = "", + string includeCalculated = "", string include = "", string handle = "query", any cache = "", @@ -457,6 +461,7 @@ component { * @where [see:findAll]. * @order [see:findAll]. * @select [see:findAll]. + * @includeCalculated [see:findAll]. * @include [see:findAll]. * @handle [see:findByKey]. * @cache [see:findAll]. @@ -471,6 +476,7 @@ component { string where = "", string order = "", string select = "", + string includeCalculated = "", string include = "", string handle = "query", any cache = "", diff --git a/vendor/wheels/model/sql.cfc b/vendor/wheels/model/sql.cfc index 773068beb9..fc4f1c666e 100644 --- a/vendor/wheels/model/sql.cfc +++ b/vendor/wheels/model/sql.cfc @@ -92,111 +92,104 @@ component { includeSoftDeletes = arguments.includeSoftDeletes ); - // Check if we need to nest inner joins (when both inner and outer joins are present) - // Only apply nesting for HABTM patterns, not for all mixed join scenarios - local.hasInnerJoins = false; - local.hasOuterJoins = false; - local.hasThroughAssociation = false; local.iEnd = ArrayLen(local.associations); - - // Check if this is specifically a through association pattern - local.originalInclude = Replace(arguments.include, " ", "", "all"); - if (Find("(", local.originalInclude)) { - // Parse the include to see if it matches through pattern: intermediate(target) - local.includePattern = ReFindNoCase("^([^(]+)\(([^)]+)\)$", local.originalInclude, 1, true); - if (ArrayLen(local.includePattern.pos) >= 3) { - local.hasThroughAssociation = true; + + // Build the join statements. Every association carries the position of the + // association it is nested under (`parentPosition`, 0 at the root), so the + // grouping decision below reads the include structure instead of re-deriving + // it from the generated SQL text. + // + // This replaces a gate that only grouped when the include string matched + // `^([^(]+)\(([^)]+)\)$` — i.e. only when the nested group came LAST. Whether + // a join is scoped correctly is a property of the association tree, not of + // where the user happened to type the parentheses, and the old anchored + // pattern made `a(b),c` and `c,a(b)` generate different SQL for the same query + // (issue #3334). + local.joins = []; + + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.indexHint = this.$indexHint( + useIndex = arguments.useIndex, + modelName = local.associations[local.i].modelName, + adapterName = arguments.adapterName + ); + local.join = local.associations[local.i].join; + if (Len(local.indexHint)) { + // replace the quoted table name with the quoted table name & index hint + // TODO: factor in table aliases.. the index hint is placed after the table alias + local.quotedAssocTable = variables.wheels.class.adapter.$quoteIdentifier(local.associations[local.i].tableName); + local.join = Replace( + local.join, + " #local.quotedAssocTable# ", + " #local.quotedAssocTable# #local.indexHint# ", + "one" + ); } + local.joins[local.i] = local.join; } - + + // Decide which INNER joins get pulled inside a parenthesized group. An INNER join + // belongs to exactly one OUTER join — the association it is nested under in the + // include string — and must never be copied into a sibling, which would reference + // a table the query has not introduced yet (issue #3334: ORA-00904 / MySQL + // "unknown column in on clause"). Prior to this the loop appended every INNER join + // to every OUTER join, which only looked correct because issues #449 and #3245 both + // exercise a single OUTER join. A root-level INNER join (`parentPosition` 0) has no + // enclosing group and stays flat, keeping the root FROM table in scope for its ON. + // + // Join type comes from the association's `joinType`, not from scanning the + // generated SQL for "INNER". The text scan the pre-fix code used misreads any + // table whose name contains the substring — `winners`, `spinners`, `beginners` + // — as an inner join, which would emit its nested child flat and silently drop + // the parent rows this fix exists to preserve. `joinType` is the authoritative + // source: it is what the join string is built from a few hundred lines below. + local.nestedJoins = {}; + local.isNested = {}; for (local.i = 1; local.i <= local.iEnd; local.i++) { - if (FindNoCase("INNER", local.associations[local.i].join)) { - local.hasInnerJoins = true; - } - if (FindNoCase("OUTER", local.associations[local.i].join) || FindNoCase("LEFT", local.associations[local.i].join)) { - local.hasOuterJoins = true; + local.parentPosition = StructKeyExists(local.associations[local.i], "parentPosition") + ? local.associations[local.i].parentPosition + : 0; + if ( + $associationJoinsInner(local.associations[local.i], local.joins[local.i]) + && local.parentPosition > 0 + && !$associationJoinsInner(local.associations[local.parentPosition], local.joins[local.parentPosition]) + ) { + if (!StructKeyExists(local.nestedJoins, local.parentPosition)) { + local.nestedJoins[local.parentPosition] = []; + } + ArrayAppend(local.nestedJoins[local.parentPosition], local.joins[local.i]); + local.isNested[local.i] = true; } } - - // Only apply nesting for through associations with mixed join types - local.needsNesting = local.hasInnerJoins && local.hasOuterJoins && local.hasThroughAssociation; - // build the join statements - if (local.needsNesting) { - // group inner joins with parentheses and outer joins separately - local.innerJoins = []; - local.outerJoins = []; + for (local.i = 1; local.i <= local.iEnd; local.i++) { + if (!StructKeyExists(local.isNested, local.i)) { + local.join = local.joins[local.i]; - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.indexHint = this.$indexHint( - useIndex = arguments.useIndex, - modelName = local.associations[local.i].modelName, - adapterName = arguments.adapterName - ); - local.join = local.associations[local.i].join; - if (Len(local.indexHint)) { - // replace the quoted table name with the quoted table name & index hint - // TODO: factor in table aliases.. the index hint is placed after the table alias - local.quotedAssocTable = variables.wheels.class.adapter.$quoteIdentifier(local.associations[local.i].tableName); - local.join = Replace( - local.join, - " #local.quotedAssocTable# ", - " #local.quotedAssocTable# #local.indexHint# ", - "one" - ); - } - - if (FindNoCase("INNER", local.join)) { - ArrayAppend(local.innerJoins, local.join); - } else { - ArrayAppend(local.outerJoins, local.join); - } - } - - for (local.i = 1; local.i <= ArrayLen(local.outerJoins); local.i++) { - local.outerJoin = local.outerJoins[local.i]; - - // If we have inner joins, we need to group them in the outer join - if (ArrayLen(local.innerJoins) > 0) { + if (StructKeyExists(local.nestedJoins, local.i)) { // Find the table being joined in the outer join - local.joinTableMatch = ReFindNoCase("LEFT OUTER JOIN ([^\s]+)", local.outerJoin, 1, true); + local.joinTableMatch = ReFindNoCase("LEFT OUTER JOIN ([^\s]+)", local.join, 1, true); if (ArrayLen(local.joinTableMatch.pos) >= 2 && local.joinTableMatch.pos[2] > 0) { - local.joinTable = Mid(local.outerJoin, local.joinTableMatch.pos[2], local.joinTableMatch.len[2]); - + local.joinTable = Mid(local.join, local.joinTableMatch.pos[2], local.joinTableMatch.len[2]); + // Build grouped inner joins: (subscriptions INNER JOIN magazines ON ...) local.groupedInner = "(" & local.joinTable; - for (local.j = 1; local.j <= ArrayLen(local.innerJoins); local.j++) { - local.groupedInner &= " " & local.innerJoins[local.j]; + local.jEnd = ArrayLen(local.nestedJoins[local.i]); + for (local.j = 1; local.j <= local.jEnd; local.j++) { + local.groupedInner &= " " & local.nestedJoins[local.i][local.j]; } local.groupedInner &= ")"; - + // Replace in the outer join - local.outerJoin = Replace(local.outerJoin, "LEFT OUTER JOIN " & local.joinTable, "LEFT OUTER JOIN " & local.groupedInner); + local.join = Replace( + local.join, + "LEFT OUTER JOIN " & local.joinTable, + "LEFT OUTER JOIN " & local.groupedInner, + "one" + ); } } - - local.rv = ListAppend(local.rv, local.outerJoin, " "); - } - } else { - // original logic for when nesting is not needed - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.indexHint = this.$indexHint( - useIndex = arguments.useIndex, - modelName = local.associations[local.i].modelName, - adapterName = arguments.adapterName - ); - local.join = local.associations[local.i].join; - if (Len(local.indexHint)) { - // replace the quoted table name with the quoted table name & index hint - // TODO: factor in table aliases.. the index hint is placed after the table alias - local.quotedAssocTable = variables.wheels.class.adapter.$quoteIdentifier(local.associations[local.i].tableName); - local.join = Replace( - local.join, - " #local.quotedAssocTable# ", - " #local.quotedAssocTable# #local.indexHint# ", - "one" - ); - } + local.rv = ListAppend(local.rv, local.join, " "); } } @@ -411,14 +404,16 @@ component { required string select, required string include, boolean includeSoftDeletes = "false", - required string returnAs + required string returnAs, + string includeCalculated = "" ) { local.rv = $createSQLFieldList( clause = "select", list = arguments.select, include = arguments.include, includeSoftDeletes = arguments.includeSoftDeletes, - returnAs = arguments.returnAs + returnAs = arguments.returnAs, + includeCalculated = arguments.includeCalculated ); // Look for " AS " followed by text containing multiple dots (namespaced aliases) @@ -471,7 +466,8 @@ component { required string include, required string returnAs, boolean includeSoftDeletes = "false", - boolean useExpandedColumnAliases = "#application.wheels.useExpandedColumnAliases#" + boolean useExpandedColumnAliases = "#application.wheels.useExpandedColumnAliases#", + string includeCalculated = "" ) { // setup an array containing class info for current class and all the ones that should be included local.classes = []; @@ -504,6 +500,36 @@ component { } } + // Additively opt in any calculated properties named via `includeCalculated` (issue #3252). + // These are typically declared `select=false`, so they are absent from the default list + // above; merging them here keeps every base column in place (additive, never replacing). + if (Len(arguments.includeCalculated)) { + local.calcArray = ListToArray(arguments.includeCalculated); + local.calcEnd = ArrayLen(local.calcArray); + for (local.c = 1; local.c <= local.calcEnd; local.c++) { + local.calcName = Trim(local.calcArray[local.c]); + if (!Len(local.calcName)) { + continue; + } + if (!StructKeyExists(variables.wheels.class.calculatedProperties, local.calcName)) { + // Dev/testing fail loud on a typo; no-op in production (mirrors existing + // dev-only validation such as Wheels.PaginationNav.InvalidArgument). + if (ListFindNoCase("development,testing", get("environment"))) { + Throw( + type = "Wheels.CalculatedPropertyNotFound", + message = "The calculated property `#local.calcName#` was not found on the `#variables.wheels.class.modelName#` model.", + extendedInfo = "The `includeCalculated` argument only accepts the names of calculated properties declared via `property(name=""..."", sql=""..."")` in the model's `config()`. Declared calculated properties: #StructKeyList(variables.wheels.class.calculatedProperties)#." + ); + } + continue; + } + // dedup: $createSQLFieldList already de-duplicates, but skip obvious repeats + if (!ListFindNoCase(arguments.list, local.calcName)) { + arguments.list = ListAppend(arguments.list, local.calcName); + } + } + } + // go through the properties and map them to the database unless the developer passed in a table name or an alias in which case we assume they know what they're doing and leave the select clause as is /* To fix the issue below: @@ -1265,12 +1291,38 @@ component { /** * Internal function. */ + /** + * Internal function. + * Whether an association contributes an INNER JOIN. + * + * Reads the association's declared `joinType` — the same value `$expandedAssociations` + * turns into the leading `INNER JOIN` / `LEFT OUTER JOIN` text — rather than searching the + * built SQL for "INNER". A substring search misclassifies every table whose name contains + * it (`winners`, `spinners`, `beginners`), and in `$fromClause` that would demote a nested + * group to a flat join and silently drop parent rows. + * + * Falls back to the text scan only if an entry somehow carries no `joinType`, which keeps + * this total for any caller assembling association structs by hand. + */ + public boolean function $associationJoinsInner(required struct association, required string join) { + if (StructKeyExists(arguments.association, "joinType") && Len(arguments.association.joinType)) { + return arguments.association.joinType == "inner"; + } + return FindNoCase("INNER", arguments.join) > 0; + } + public array function $expandedAssociations(required string include, boolean includeSoftDeletes = "false") { local.rv = []; // add the current class name so that the levels list start at the lowest level local.levels = variables.wheels.class.modelName; + // mirrors `local.levels` with the position in `local.rv` of the association that + // opened each level, so every entry can record the association it nests under. + // Callers that group joins (see `$fromClause`) would otherwise have to re-derive + // parentage from the generated SQL text — the regex guesswork behind issue #3334. + local.parentPositions = []; + // expand through associations before processing local.include = $expandThroughAssociations(arguments.include); @@ -1286,6 +1338,9 @@ component { local.pos = 1; for (local.i = 1; local.i <= local.iEnd; local.i++) { + // the association that opened the level we are currently inside, or 0 at the root + local.parentPosition = ArrayLen(local.parentPositions) ? local.parentPositions[ArrayLen(local.parentPositions)] : 0; + // look for the next delimiter sequence in the string and set it (can be single delims or a chain, e.g ',' or ')),' local.delimFind = ReFind("[(\(|\)|,)]+", local.include, local.pos, true); local.delimSequence = Mid(local.include, local.delimFind.pos[1], local.delimFind.len[1]); @@ -1332,13 +1387,39 @@ component { lock name="wheelsJoinMemo#application.applicationName#" type="exclusive" timeout="10" { if (!StructKeyExists(local.classAssociations[local.name], "expandedMetadataFilled")) { if (!Len(local.classAssociations[local.name].foreignKey)) { - // cfformat-ignore-start + // The foreign key column lives on a different side depending on the association + // type: for `belongsTo` it is a column on THIS model's table, for `hasMany` / + // `hasOne` it is a column on the ASSOCIATED model's table. Resolve the default + // against whichever side actually owns it so both the legacy `` + // form and the `_` form that `useUnderscoreReferenceColumns` + // makes the migrator emit are honoured (#3337). if (local.classAssociations[local.name].type == "belongsTo") { - local.classAssociations[local.name].foreignKey = local.associatedClass.$classData().modelName & Replace(local.associatedClass.$classData().keys, ",", ",#local.associatedClass.$classData().modelName#", "all"); + local.fkNameSource = local.associatedClass; + local.fkColumnOwner = local.class; } else { - local.classAssociations[local.name].foreignKey = local.class.$classData().modelName & Replace(local.class.$classData().keys, ",", ",#local.class.$classData().modelName#", "all"); + local.fkNameSource = local.class; + local.fkColumnOwner = local.associatedClass; + } + local.classAssociations[local.name].foreignKey = $deriveAssociationForeignKey( + columnOwner = local.fkColumnOwner, + modelName = local.fkNameSource.$classData().modelName, + keys = local.fkNameSource.$classData().keys + ); + // A derived default matching no column on the owning side can only fail later, + // deep inside the join builder, as `key [xxx] doesn't exist` — a message naming + // neither the association nor the `foreignKey=` argument that fixes it. Report it + // here instead, while both candidate shapes are still in hand (#3337). Runs inside + // the memo so the success path costs one check per application lifetime, and only + // for defaults derived here — an explicit `foreignKey=` is the developer's call. + if (application.wheels.showErrorInformation) { + $assertDerivedForeignKeyResolves( + associationName = local.name, + foreignKey = local.classAssociations[local.name].foreignKey, + columnOwner = local.fkColumnOwner, + modelName = local.fkNameSource.$classData().modelName, + keys = local.fkNameSource.$classData().keys + ); } - // cfformat-ignore-end } if (!Len(local.classAssociations[local.name].joinKey)) { if (local.classAssociations[local.name].type == "belongsTo") { @@ -1471,8 +1552,18 @@ component { local.delimChar = Mid(local.delimSequence, local.j, 1); if (local.delimChar == "(") { local.levels = ListAppend(local.levels, local.classAssociations[local.name].modelName); + // this association parents everything inside the parentheses it just opened; + // `local.i` is its position in `local.rv` because we append exactly once per pass + ArrayAppend(local.parentPositions, local.i); } else if (local.delimChar == ")") { local.levels = ListDeleteAt(local.levels, ListLen(local.levels)); + // Guarded because an unbalanced include (`"posts)"`) reaches here with an + // empty stack, and ArrayDeleteAt(x, 0) throws where the ListDeleteAt above + // quietly tolerates it. Malformed includes behaved as before this change; + // they should not start erroring differently because of it. + if (ArrayLen(local.parentPositions)) { + ArrayDeleteAt(local.parentPositions, ArrayLen(local.parentPositions)); + } } } @@ -1490,11 +1581,123 @@ component { // identifiers contain the ON substring (e.g. uppercase H2 schemas) local.onPos = Find(" ON ", local.entry.join); local.entry.joinOnConditions = local.onPos GT 0 ? Mid(local.entry.join, local.onPos + 4, Len(local.entry.join)) : ""; + // position in this array of the association this one is nested under (0 = root level) + local.entry.parentPosition = local.parentPosition; ArrayAppend(local.rv, local.entry); } return local.rv; } + /** + * Internal function. + * Builds the conventional foreign key list for an association default: the model name + * prefixed onto each of the target primary keys, joined by `separator`. + * + * `keys` may be a comma list for composite primary keys, so every element gets the + * prefix — `user` + `a,b` yields `usera,userb`, or `user_a,user_b` with an underscore. + */ + public string function $buildForeignKeyList( + required string modelName, + required string keys, + string separator = "" + ) { + local.rv = ""; + local.keysArray = ListToArray(arguments.keys); + local.iEnd = ArrayLen(local.keysArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.rv = ListAppend(local.rv, arguments.modelName & arguments.separator & Trim(local.keysArray[local.i])); + } + return local.rv; + } + + /** + * Internal function. + * True when every element of a foreign key list is a property on the supplied class. + * Checks property names rather than column names because that is the lookup the join + * builder performs (`properties[foreignKey].column`). + */ + public boolean function $foreignKeyListResolves(required any columnOwner, required string foreignKey) { + local.properties = arguments.columnOwner.$classData().properties; + local.keysArray = ListToArray(arguments.foreignKey); + local.iEnd = ArrayLen(local.keysArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + if (!StructKeyExists(local.properties, Trim(local.keysArray[local.i]))) { + return false; + } + } + return local.iEnd > 0; + } + + /** + * Internal function. + * Derives the default foreign key for an association, preferring whichever conventional + * shape actually exists on the model that owns the column. + * + * Wheels has two conventions in play. The legacy `` form is what this + * function has always produced, and `useUnderscoreReferenceColumns` (the `wheels new` + * default) makes the migrator emit `_` instead — leaving stock new apps + * with a schema the association default could never match (#3337). + * + * Resolving against the real columns rather than reading the setting fixes both + * conventions at once, including apps that flipped the flag mid-life and therefore hold + * a mix of both shapes. It is also strictly error-reducing: the underscore form is only + * consulted when the legacy form is absent, which is a case that throws today. The + * setting is deliberately NOT consulted — it is read per call by the migrator, whereas + * this result is memoized for the application lifetime, so honouring it here would make + * a runtime flip take effect for migrations but not for models. + * + * Falls back to the legacy shape when neither resolves, leaving the existing error path + * (and `$assertDerivedForeignKeyResolves`) to report it. + */ + public string function $deriveAssociationForeignKey( + required any columnOwner, + required string modelName, + required string keys + ) { + local.legacy = $buildForeignKeyList(modelName = arguments.modelName, keys = arguments.keys); + if ($foreignKeyListResolves(columnOwner = arguments.columnOwner, foreignKey = local.legacy)) { + return local.legacy; + } + local.underscored = $buildForeignKeyList(modelName = arguments.modelName, keys = arguments.keys, separator = "_"); + if ($foreignKeyListResolves(columnOwner = arguments.columnOwner, foreignKey = local.underscored)) { + return local.underscored; + } + return local.legacy; + } + + /** + * Internal function. + * Throws a descriptive error when an association's DERIVED default foreign key matches no + * property on the model that owns the column. Without this the failure surfaces much later + * as `key [xxx] doesn't exist` from inside the join builder, which names neither the + * association nor the argument that fixes it (#3337). + * + * Skipped when the owner has no properties at all — an un-migrated or missing table would + * otherwise produce this error instead of the clearer one the query itself raises. + */ + public void function $assertDerivedForeignKeyResolves( + required string associationName, + required string foreignKey, + required any columnOwner, + required string modelName, + required string keys + ) { + if (!StructCount(arguments.columnOwner.$classData().properties)) { + return; + } + if ($foreignKeyListResolves(columnOwner = arguments.columnOwner, foreignKey = arguments.foreignKey)) { + return; + } + local.ownerName = arguments.columnOwner.$classData().modelName; + local.legacy = $buildForeignKeyList(modelName = arguments.modelName, keys = arguments.keys); + local.underscored = $buildForeignKeyList(modelName = arguments.modelName, keys = arguments.keys, separator = "_"); + Throw( + type = "Wheels.AssociationForeignKeyNotFound", + message = "The `#arguments.associationName#` association derives a default foreign key of `#arguments.foreignKey#`, which is not a property on the `#local.ownerName#` model.", + extendedInfo = "Wheels looks for the conventional `#local.legacy#` and, for schemas built with `useUnderscoreReferenceColumns` enabled, `#local.underscored#`. Neither exists on `#local.ownerName#`. Either pass `foreignKey=""""` explicitly when setting up the `#arguments.associationName#` association, or rename the column on `#local.ownerName#` to one of those two forms." + ); + } + /** * Internal function. */ diff --git a/vendor/wheels/model/transactions.cfc b/vendor/wheels/model/transactions.cfc index 84add8f5ad..d41488e8a1 100644 --- a/vendor/wheels/model/transactions.cfc +++ b/vendor/wheels/model/transactions.cfc @@ -55,17 +55,33 @@ component { switch (arguments.transaction) { case "commit": case "rollback": - transaction action="begin" isolation=arguments.isolation { - try { - local.rv = $invoke(method = arguments.method, componentReference = this, invokeArgs = local.methodArgs); - if (!IsBoolean(local.rv) || !local.rv || arguments.transaction eq "rollback") { + // The outer try/catch exists because the `transaction action="begin"` + // tag can throw before the inner one is ever entered — an unsupported + // isolation level, a nested-isolation mismatch on Adobe, a dead + // connection. The open marker is set above, so without this the + // marker stayed `true` for the rest of the request and every later + // invokeWithTransaction took the "alreadyopen" path and silently ran + // with no transaction at all. The whole core suite runs in one + // request, which is how a single throwing begin in + // CockroachDBTransactionSpec went on to fail OuterTransactionSignalSpec + // several bundles later (#3302). Resetting twice is harmless: the + // inner catch already clears the same flag before it rethrows. + try { + transaction action="begin" isolation=arguments.isolation { + try { + local.rv = $invoke(method = arguments.method, componentReference = this, invokeArgs = local.methodArgs); + if (!IsBoolean(local.rv) || !local.rv || arguments.transaction eq "rollback") { + transaction action="rollback"; + } + } catch (any e) { transaction action="rollback"; + request.wheels.transactions[local.connectionArgs] = false; + rethrow; } - } catch (any e) { - transaction action="rollback"; - request.wheels.transactions[local.connectionArgs] = false; - rethrow; } + } catch (any e) { + request.wheels.transactions[local.connectionArgs] = false; + rethrow; } break; case "false": diff --git a/vendor/wheels/model/validations.cfc b/vendor/wheels/model/validations.cfc index 53aa704755..b1dffdeba7 100644 --- a/vendor/wheels/model/validations.cfc +++ b/vendor/wheels/model/validations.cfc @@ -584,7 +584,7 @@ component { Throw( type = "Wheels.InvalidValidationCondition", message = "The `#local.item#` expression `#arguments[local.item]#` could not be evaluated: #e.message#", - extendedInfo = "Supported forms: `this.property`, `this.method()`, bare `method()` (optionally negated with `!`), and binary comparisons using eq/neq/lt/lte/gt/gte or ==/!=//>=." + extendedInfo = "Supported forms: `this.property`, `this.method()` (with named `key='val'` or positional `'val'` arguments), bare `method()` (optionally negated with `!`), and binary comparisons using eq/neq/lt/lte/gt/gte or ==/!=//>=." ); } cflog( @@ -783,8 +783,18 @@ component { * and converting blank numeric properties to IS NULL. */ public string function $buildWhereClausePart(required string property) { + // A property named in `scope=` may be absent rather than empty: `$setDefaultValues()` + // only seeds properties with an explicit `property()` mapping, so a column that has a + // database-level default but no mapping never appears on a `new()`-ed object. Reading + // it unguarded threw "has no accessible Member" out of a validation, so an absent + // scope property produced an exception where a blank one produced a validation result + // (issue #3350). The validated property itself cannot be absent here — + // `$shouldInvokeValidation()` skips the validation in that case — so this guard only + // ever fires for scopes. Treat absent as blank, which is the branch below that turns + // an empty numeric into `IS NULL`. + local.value = StructKeyExists(this, arguments.property) ? this[arguments.property] : ""; local.part = arguments.property & "=" & variables.wheels.class.adapter.$quoteValue( - str = this[arguments.property], + str = local.value, type = validationTypeForProperty(arguments.property) ); if (Right(local.part, 3) == "=''" && ListFindNoCase("integer,float,boolean", validationTypeForProperty(arguments.property))) { @@ -897,7 +907,7 @@ component { } if (StructKeyExists(this, local.methodName)) { if (IsCustomFunction(this[local.methodName])) { - local.rv.value = invoke(this, local.methodName, $parseConditionArgs(local.argsRaw)); + local.rv.value = invoke(this, local.methodName, $parseConditionArgs(local.argsRaw, this[local.methodName])); } else { local.rv.value = this[local.methodName]; } @@ -917,24 +927,62 @@ component { } /** - * Parses a comma-delimited argument string (e.g. "key1='val1',key2='val2'") - * into a struct of named arguments. + * Parses a comma-delimited argument string into a struct of named arguments + * suitable for `invoke()`. Handles both named (`key='val'`) and positional + * (`'val'`) arguments. Positional arguments are mapped onto the target + * function's declared parameter names, so a condition like + * `this.propertyIsPresent('productid')` resolves correctly (#3238). + * + * Named-argument invoke() resolves uniformly across Lucee/Adobe/BoxLang; + * a numeric-keyed positional argumentCollection does not — hence the mapping. + * + * Note: arguments are split on "," so a quoted value containing a comma + * (e.g. `'a,b'`) is not yet supported — this matches the prior named-arg + * behaviour and is out of scope for #3238. */ - public struct function $parseConditionArgs(required string argsString) { - local.rv = {}; + public struct function $parseConditionArgs(required string argsString, any targetFunction) { + local.named = {}; + local.positional = []; for (local.param in ListToArray(arguments.argsString, ",")) { local.param = Trim(local.param); + if (!Len(local.param)) { + continue; + } if (Find("=", local.param)) { - local.splitArg = ListToArray(local.param, "="); - if (ArrayLen(local.splitArg) >= 2) { - local.varName = Trim(local.splitArg[1]); - local.varValue = Trim(local.splitArg[2]); - local.varValue = Replace(local.varValue, "'", "", "all"); - local.varValue = Replace(local.varValue, '"', "", "all"); - local.rv[local.varName] = local.varValue; + local.varName = Trim(ListFirst(local.param, "=")); + local.named[local.varName] = $unquoteConditionValue(Trim(ListRest(local.param, "="))); + } else { + ArrayAppend(local.positional, $unquoteConditionValue(local.param)); + } + } + + // Map any positional arguments onto the target function's declared + // parameter names so the result is a single named-argument struct. + if (ArrayLen(local.positional) && StructKeyExists(arguments, "targetFunction")) { + local.paramNames = $conditionFunctionParameterNames(arguments.targetFunction); + for (local.i = 1; local.i <= ArrayLen(local.positional); local.i++) { + if (local.i <= ArrayLen(local.paramNames) && !StructKeyExists(local.named, local.paramNames[local.i])) { + local.named[local.paramNames[local.i]] = local.positional[local.i]; } } } + + return local.named; + } + + /** + * Returns the ordered parameter names declared by a function reference. + * Used to map positional condition-method arguments onto named arguments + * (#3238). + */ + public array function $conditionFunctionParameterNames(required any targetFunction) { + local.rv = []; + local.meta = GetMetaData(arguments.targetFunction); + if (StructKeyExists(local.meta, "parameters") && IsArray(local.meta.parameters)) { + for (local.param in local.meta.parameters) { + ArrayAppend(local.rv, local.param.name); + } + } return local.rv; } diff --git a/vendor/wheels/public/CliBridge.cfc b/vendor/wheels/public/CliBridge.cfc new file mode 100644 index 0000000000..eb1d898283 --- /dev/null +++ b/vendor/wheels/public/CliBridge.cfc @@ -0,0 +1,931 @@ +/** + * CliBridge — dev-UI / CLI command handlers, extracted from the former + * 935-line `vendor/wheels/public/views/cli.cfm` god template (issue #2959, + * review finding P2). + * + * cli.cfm is now a thin dispatcher: it builds the response envelope preamble + * (security gate, lazy migration discovery, version/db-type), constructs a + * `context` struct, then asks this service to run the command. Each command + * is one method here, so the handlers are individually unit-testable — the + * regression net the template never had. + * + * Dispatch is allowlist-gated: `variables.commandMap` maps a command name to + * a handler method name, and `dispatch()` only ever `invoke()`s a method that + * appears in that map. `params` is passed to handlers as a single named + * argument (never spread), so a query-string key cannot become an arbitrary + * function argument (the remote arg-injection risk flagged in the #2959 + * cross-framework research). + * + * The component is stateless (only the immutable `commandMap` lives in + * `variables`), so a single instance is cached on `application.wheels` and + * shared across concurrent requests; `?reload=true` rebuilds `application` + * and re-creates it. Framework primitives the handlers need (`model()`, + * `get()`, `$cliFormatMigrationStatus()`, `$cliResolveDumpPath()`) are reached + * through `context.host` — the `wheels.Public` instance that includes cli.cfm. + * + * Context shape (built by cli.cfm): + * { host, migrator, datasource, databaseType, currentVersion, + * lastVersion, migrations } + * + * Each handler returns a partial struct that the dispatcher merges into the + * response envelope via `StructAppend(data, result, true)`. + */ +component output="false" displayName="CLI Bridge" { + + public any function init() { + // Allowlist: command name -> handler method name. Command names match + // method names, but the explicit map (not a name-equality shortcut) is + // what prevents `init`, `dispatch`, `handles`, or any other component + // method from being reachable as a command. + variables.commandMap = { + // Migration commands + "createMigration" = "createMigration", + "migrateTo" = "migrateTo", + "migrateToLatest" = "migrateToLatest", + "migrateUp" = "migrateUp", + "migrateDown" = "migrateDown", + "renameSystemTables" = "renameSystemTables", + "diff" = "diff", + "redoMigration" = "redoMigration", + "info" = "info", + "doctor" = "doctor", + "forgetVersion" = "forgetVersion", + "pretendVersion" = "pretendVersion", + // Database commands + "dbStatus" = "dbStatus", + "dbVersion" = "dbVersion", + "dbRollback" = "dbRollback", + "dbSchema" = "dbSchema", + "introspect" = "introspect", + "dbSeed" = "dbSeed", + "routes" = "routes", + "dbCreate" = "dbCreate", + "dbDrop" = "dbDrop", + "dbReset" = "dbReset", + "dbSetup" = "dbSetup", + "dbDump" = "dbDump", + "dbRestore" = "dbRestore", + "dbShell" = "dbShell", + // Job worker commands + "jobsProcessNext" = "jobsProcessNext", + "jobsStatus" = "jobsStatus", + "jobsRetry" = "jobsRetry", + "jobsPurge" = "jobsPurge", + "jobsMonitor" = "jobsMonitor" + }; + return this; + } + + /** + * Whether `command` is a declared, dispatchable command. + */ + public boolean function handles(required string command) { + return Len(arguments.command) && StructKeyExists(variables.commandMap, arguments.command); + } + + /** + * Run a command. Throws `Wheels.UnknownCliCommand` if the command is not on + * the allowlist — callers gate on `handles()` first to preserve the legacy + * "unknown command is a silent no-op" envelope behaviour. + */ + public struct function dispatch(required string command, required struct context, required struct params) { + if (!handles(arguments.command)) { + Throw( + type = "Wheels.UnknownCliCommand", + message = "Unknown CLI command: " & arguments.command + ); + } + local.method = variables.commandMap[arguments.command]; + return invoke(this, local.method, {context = arguments.context, params = arguments.params}); + } + + // ── Migration commands ────────────────────────────────────────────── + + public struct function createMigration(required struct context, required struct params) { + local.rv = {}; + if (StructKeyExists(arguments.params, "migrationPrefix") && Len(arguments.params.migrationPrefix)) { + local.rv.message = arguments.context.migrator.createMigration( + arguments.params.migrationName, + arguments.params.templateName, + arguments.params.migrationPrefix + ); + } else { + local.rv.message = arguments.context.migrator.createMigration( + arguments.params.migrationName, + arguments.params.templateName + ); + } + return local.rv; + } + + public struct function migrateTo(required struct context, required struct params) { + local.rv = {}; + if (StructKeyExists(arguments.params, "version")) { + local.rv.message = arguments.context.migrator.migrateTo(arguments.params.version); + } + return local.rv; + } + + public struct function migrateToLatest(required struct context, required struct params) { + return {message = arguments.context.migrator.migrateToLatest()}; + } + + public struct function migrateUp(required struct context, required struct params) { + // Walk the migration list (sorted ascending by version) and migrate to + // the first pending version after the current one. + local.rv = {}; + local.targetVersion = ""; + for (local.m in arguments.context.migrations) { + if (local.m.status != "migrated" && local.m.version > arguments.context.currentVersion) { + local.targetVersion = local.m.version; + break; + } + } + if (Len(local.targetVersion)) { + local.rv.message = arguments.context.migrator.migrateTo(local.targetVersion); + } else { + local.rv.message = "No pending migrations. Database is at version #arguments.context.currentVersion#."; + } + return local.rv; + } + + public struct function migrateDown(required struct context, required struct params) { + // Walk the list in reverse to find the migration immediately below the + // current version, then migrate down to it. + local.rv = {}; + local.targetVersion = "0"; + for (local.i = ArrayLen(arguments.context.migrations); local.i >= 1; local.i--) { + local.m = arguments.context.migrations[local.i]; + if (local.m.version < arguments.context.currentVersion && local.m.status == "migrated") { + local.targetVersion = local.m.version; + break; + } + } + if (arguments.context.currentVersion == "0") { + local.rv.message = "Database is at version 0; nothing to roll back."; + } else { + local.rv.message = arguments.context.migrator.migrateTo(local.targetVersion); + } + return local.rv; + } + + public struct function renameSystemTables(required struct context, required struct params) { + // F15 Phase 2: opt-in rename of legacy c_o_r_e_* system tables. + local.rv = {}; + local.dryRun = (StructKeyExists(arguments.params, "dryRun") && arguments.params.dryRun == "true"); + local.rv.renameResult = arguments.context.migrator.renameSystemTables(dryRun = local.dryRun); + local.rv.success = local.rv.renameResult.success; + if (Len(local.rv.renameResult.skipped)) { + local.rv.message = local.rv.renameResult.skipped; + } else if (ArrayLen(local.rv.renameResult.renamed)) { + local.rv.message = "Renamed: " & ArrayToList(local.rv.renameResult.renamed, "; "); + } else if (local.dryRun && ArrayLen(local.rv.renameResult.sql)) { + local.rv.message = "Dry run — SQL that would execute:" & Chr(10) & ArrayToList(local.rv.renameResult.sql, ";" & Chr(10)) & ";"; + } + return local.rv; + } + + public struct function diff(required struct context, required struct params) { + local.rv = {}; + try { + local.autoMigrator = CreateObject("component", "wheels.migrator.AutoMigrator"); + local.options = {}; + + // Parse hints from URL: hints={"renames":{"old":"new"}} as JSON. + if (StructKeyExists(arguments.params, "hints") && Len(arguments.params.hints)) { + local.decodedHints = DeserializeJSON(arguments.params.hints); + if (IsStruct(local.decodedHints)) { + StructAppend(local.options, local.decodedHints, true); + } + } + if (StructKeyExists(arguments.params, "threshold") && Len(arguments.params.threshold) && IsNumeric(arguments.params.threshold)) { + local.options.heuristicThreshold = arguments.params.threshold; + } + + if (StructKeyExists(arguments.params, "modelName") && Len(arguments.params.modelName)) { + local.diffResult = local.autoMigrator.diff(arguments.params.modelName, local.options); + + // Optionally write the migration file. + local.migrationWritten = ""; + if (StructKeyExists(arguments.params, "write") && arguments.params.write == "true") { + local.migName = StructKeyExists(arguments.params, "name") && Len(arguments.params.name) ? arguments.params.name : ""; + local.autoMigrator.writeMigration(local.diffResult, local.migName); + local.migrationWritten = "written"; + } + + local.rv.success = true; + local.rv.model = local.diffResult; + local.rv.migrationWritten = local.migrationWritten; + } else { + // diffAll path + local.diffAllResult = local.autoMigrator.diffAll(local.options); + + local.written = []; + if (StructKeyExists(arguments.params, "write") && arguments.params.write == "true") { + for (local.m in local.diffAllResult) { + local.autoMigrator.writeMigration(local.diffAllResult[local.m], ""); + ArrayAppend(local.written, local.m); + } + } + + local.rv.success = true; + local.rv.models = local.diffAllResult; + local.rv.migrationsWritten = local.written; + } + } catch (any e) { + local.rv.success = false; + local.rv.error = e.type; + local.rv.message = e.message; + } + return local.rv; + } + + public struct function redoMigration(required struct context, required struct params) { + local.rv = {}; + if (StructKeyExists(arguments.params, "version")) { + local.redoVersion = arguments.params.version; + } else { + local.redoVersion = arguments.context.lastVersion; + } + local.rv.message = arguments.context.migrator.redoMigration(local.redoVersion); + return local.rv; + } + + public struct function info(required struct context, required struct params) { + // Build a human-readable status block; the migrations list is rendered + // by Migrator.$buildInfoOutput() so the logic is unit-testable. + local.rv = {}; + local.lines = []; + ArrayAppend(local.lines, "Datasource: " & arguments.context.datasource); + ArrayAppend(local.lines, "Database type: " & arguments.context.databaseType); + for (local.line in arguments.context.migrator.$buildInfoOutput()) { + ArrayAppend(local.lines, local.line); + } + local.rv.message = ArrayToList(local.lines, Chr(10)); + return local.rv; + } + + public struct function doctor(required struct context, required struct params) { + // Comprehensive migrator health diagnostic (#2780). + local.rv = {}; + local.report = arguments.context.migrator.doctor(); + local.rv.healthy = local.report.healthy; + local.rv.currentVersion = local.report.currentVersion; + local.rv.orphans = local.report.orphans; + local.rv.orphansWithMeta = local.report.orphansWithMeta; + local.rv.pending = local.report.pending; + local.rv.summary = local.report.summary; + local.docLines = []; + ArrayAppend(local.docLines, local.report.message); + ArrayAppend(local.docLines, ""); + ArrayAppend(local.docLines, " Datasource: " & arguments.context.datasource); + ArrayAppend(local.docLines, " Database type: " & arguments.context.databaseType); + ArrayAppend(local.docLines, " Current version: " & (Len(local.report.currentVersion) ? local.report.currentVersion : "0")); + ArrayAppend(local.docLines, " Total migrations: " & local.report.summary.total); + ArrayAppend(local.docLines, " applied: " & local.report.summary.applied); + ArrayAppend(local.docLines, " pending: " & local.report.summary.pending); + if (local.report.summary.orphan > 0) { + ArrayAppend(local.docLines, " orphan: " & local.report.summary.orphan & " (" & ArrayToList(local.report.orphans, ", ") & ")"); + } + if (ArrayLen(local.report.pending) > 0) { + ArrayAppend(local.docLines, ""); + ArrayAppend(local.docLines, "Pending local migrations:"); + for (local.v in local.report.pending) { + ArrayAppend(local.docLines, " [ ] " & local.v); + } + } + if (ArrayLen(local.report.orphansWithMeta) > 0) { + ArrayAppend(local.docLines, ""); + ArrayAppend(local.docLines, "Orphan versions (no matching file):"); + for (local.o in local.report.orphansWithMeta) { + local.orphanLine = " [?] " & local.o.version; + if (Len(local.o.name)) { + local.orphanLine &= " " & local.o.name; + } + if (Len(local.o.appliedAt)) { + local.orphanLine &= " (applied " & local.o.appliedAt & ")"; + } + ArrayAppend(local.docLines, local.orphanLine); + } + ArrayAppend(local.docLines, ""); + ArrayAppend(local.docLines, "Resolve: `wheels migrate forget --yes` to remove an orphan row,"); + ArrayAppend(local.docLines, " or pull the peer's migration file via git."); + } + local.rv.message = ArrayToList(local.docLines, Chr(10)); + return local.rv; + } + + public struct function forgetVersion(required struct context, required struct params) { + // Remove a row from wheels_migrator_versions without running down(). + local.rv = {}; + local.versionArg = arguments.params.version ?: ""; + if (!Len(local.versionArg)) { + local.rv.success = false; + local.rv.message = "Missing required argument: version. Usage: wheels migrate forget "; + return local.rv; + } + local.forgetResult = arguments.context.migrator.forgetVersion(local.versionArg); + local.rv.success = local.forgetResult.success; + local.rv.removed = local.forgetResult.removed; + local.rv.message = local.forgetResult.message; + return local.rv; + } + + public struct function pretendVersion(required struct context, required struct params) { + // Record a version as applied without running up(). + local.rv = {}; + local.pretendArg = arguments.params.version ?: ""; + if (!Len(local.pretendArg)) { + local.rv.success = false; + local.rv.message = "Missing required argument: version. Usage: wheels migrate pretend "; + return local.rv; + } + local.pretendResult = arguments.context.migrator.pretendVersion(local.pretendArg); + local.rv.success = local.pretendResult.success; + local.rv.recorded = local.pretendResult.recorded; + local.rv.message = local.pretendResult.message; + return local.rv; + } + + // ── Database commands ─────────────────────────────────────────────── + + public struct function dbStatus(required struct context, required struct params) { + // Return migration status straight from the migrator's own status field. + local.rv = {}; + local.statusReport = arguments.context.host.$cliFormatMigrationStatus(arguments.context.migrations); + local.rv.success = true; + local.rv.migrations = local.statusReport.migrations; + local.rv.summary = local.statusReport.summary; + return local.rv; + } + + public struct function dbVersion(required struct context, required struct params) { + return { + success = true, + version = arguments.context.currentVersion, + message = "Current database version: " & arguments.context.currentVersion + }; + } + + public struct function dbRollback(required struct context, required struct params) { + local.rv = {}; + local.steps = structKeyExists(arguments.params, "steps") ? arguments.params.steps : 1; + local.targetVersion = ""; + + // Filter on tracked status, not version <= current (shared dev DB; #2947/#2977). + local.appliedMigrations = []; + for (local.migration in arguments.context.migrations) { + if (local.migration.status == "migrated") { + arrayAppend(local.appliedMigrations, local.migration); + } + } + + if (arrayLen(local.appliedMigrations) >= local.steps) { + local.targetIndex = arrayLen(local.appliedMigrations) - local.steps; + if (local.targetIndex > 0) { + local.targetVersion = local.appliedMigrations[local.targetIndex].version; + } else { + local.targetVersion = "0"; + } + } + + if (len(local.targetVersion)) { + local.rv.message = arguments.context.migrator.migrateTo(local.targetVersion); + local.rv.success = true; + } else { + local.rv.success = false; + local.rv.message = "No migrations to rollback"; + } + return local.rv; + } + + public struct function dbSchema(required struct context, required struct params) { + local.rv = {success = true, schema = {}}; + + try { + local.adapter = application.wheels.dataAdapter; + local.rv.schema.databaseType = arguments.context.databaseType; + local.rv.schema.tables = []; + + // Get all tables + local.tables = []; + if (arguments.context.databaseType == "H2") { + local.tablesQuery = new Query(); + local.tablesQuery.setDatasource(application.wheels.dataSourceName); + local.tablesQuery.setSQL("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'TABLE' AND TABLE_SCHEMA = 'PUBLIC'"); + local.tables = local.tablesQuery.execute().getResult(); + } else { + local.tablesQuery = new Query(); + local.tablesQuery.setDatasource(application.wheels.dataSourceName); + local.tablesQuery.setSQL("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'"); + local.tables = local.tablesQuery.execute().getResult(); + } + + for (local.table in local.tables) { + local.tableInfo = { + name = local.table.TABLE_NAME, + columns = [] + }; + + local.columns = new Query(); + local.columns.setDatasource(application.wheels.dataSourceName); + if (arguments.context.databaseType == "H2") { + local.columns.setSQL("SELECT COLUMN_NAME, TYPE_NAME as DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = :tableName AND TABLE_SCHEMA = 'PUBLIC'"); + } else { + local.columns.setSQL("SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = :tableName"); + } + local.columns.addParam(name = "tableName", value = local.table.TABLE_NAME, cfsqltype = "cf_sql_varchar"); + local.columnResult = local.columns.execute().getResult(); + + for (local.column in local.columnResult) { + arrayAppend(local.tableInfo.columns, { + name = local.column.COLUMN_NAME, + type = local.column.DATA_TYPE, + nullable = local.column.IS_NULLABLE, + default = local.column.COLUMN_DEFAULT ?: "" + }); + } + + arrayAppend(local.rv.schema.tables, local.tableInfo); + } + } catch (any e) { + local.rv.success = false; + local.rv.message = "Error retrieving schema: " & e.message; + } + return local.rv; + } + + public struct function introspect(required struct context, required struct params) { + local.rv = {success = false}; + if (!structKeyExists(arguments.params, "model") || !len(arguments.params.model)) { + local.rv.message = "Missing required parameter: model"; + return local.rv; + } + + try { + local.modelName = arguments.params.model; + local.modelInstance = arguments.context.host.model(local.modelName); + local.classData = local.modelInstance.$classData(); + + local.rv.model = local.modelName; + local.rv.tableName = local.classData.tableName ?: lCase(local.modelName) & "s"; + local.rv.primaryKey = local.classData.keys ?: "id"; + + local.rv.columns = []; + if (structKeyExists(local.classData, "properties")) { + for (local.propName in local.classData.properties) { + local.prop = local.classData.properties[local.propName]; + local.colInfo = { + name: local.propName, + type: local.prop.type ?: "string", + primaryKey: listFindNoCase(local.rv.primaryKey, local.propName) > 0 + }; + if (structKeyExists(local.prop, "maxLength") && val(local.prop.maxLength) > 0) { + local.colInfo.maxLength = local.prop.maxLength; + } + if (right(local.propName, 2) == "Id" && len(local.propName) > 2) { + local.colInfo.foreignKey = true; + local.refName = left(local.propName, len(local.propName) - 2); + local.colInfo.referencedModel = uCase(left(local.refName, 1)) & mid(local.refName, 2, len(local.refName) - 1); + } + arrayAppend(local.rv.columns, local.colInfo); + } + } + + local.rv.associations = []; + if (structKeyExists(local.classData, "associations")) { + for (local.assocName in local.classData.associations) { + local.assoc = local.classData.associations[local.assocName]; + local.assocModelName = local.assoc.modelName ?: local.assocName; + local.assocModelName = uCase(left(local.assocModelName, 1)) & mid(local.assocModelName, 2, len(local.assocModelName) - 1); + arrayAppend(local.rv.associations, { + type: local.assoc.type ?: "belongsTo", + name: local.assocName, + modelName: local.assocModelName + }); + } + } + + local.rv.success = true; + local.rv.message = "Model introspected successfully"; + } catch (any e) { + local.rv.message = "Error introspecting model: " & e.message; + } + return local.rv; + } + + public struct function dbSeed(required struct context, required struct params) { + // Seed orchestration; `dbSetup` composes through the same private + // helper instead of re-entering the dispatcher (issue #2959). + return $runDbSeed(params = arguments.params, context = arguments.context); + } + + public struct function routes(required struct context, required struct params) { + // Routes live at application.wheels.routes. + local.rv = {success = true, routes = []}; + if (structKeyExists(application, "wheels") && structKeyExists(application.wheels, "routes")) { + for (local.route in application.wheels.routes) { + local.routeInfo = { + name = structKeyExists(local.route, "name") ? local.route.name : "", + pattern = structKeyExists(local.route, "pattern") ? local.route.pattern : "", + controller = structKeyExists(local.route, "controller") ? local.route.controller : "", + action = structKeyExists(local.route, "action") ? local.route.action : "", + methods = structKeyExists(local.route, "methods") ? local.route.methods : "GET" + }; + arrayAppend(local.rv.routes, local.routeInfo); + } + } + return local.rv; + } + + public struct function dbCreate(required struct context, required struct params) { + local.rv = {success = false}; + + // For H2, we can provide helpful info and ensure schema table exists. + if (arguments.context.databaseType == "H2") { + try { + local.checkQuery = new Query(); + local.checkQuery.setDatasource(application.wheels.dataSourceName); + local.checkQuery.setSQL("SELECT COUNT(*) as cnt FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'SCHEMAINFO'"); + local.checkResult = local.checkQuery.execute().getResult(); + + if (local.checkResult.cnt == 0) { + local.createQuery = new Query(); + local.createQuery.setDatasource(application.wheels.dataSourceName); + local.createQuery.setSQL("CREATE TABLE IF NOT EXISTS schemainfo (version VARCHAR(25) DEFAULT '0')"); + local.createQuery.execute(); + + local.insertQuery = new Query(); + local.insertQuery.setDatasource(application.wheels.dataSourceName); + local.insertQuery.setSQL("INSERT INTO schemainfo (version) VALUES ('0')"); + local.insertQuery.execute(); + + local.rv.message = "H2 database initialized successfully with schema tracking table."; + } else { + local.rv.message = "H2 database already exists and is properly configured."; + } + local.rv.success = true; + } catch (any e) { + local.rv.message = "H2 database exists but error checking schema: " & e.message; + local.rv.success = true; // Still mark as success since H2 auto-creates + } + } else { + local.rv.message = "Database creation must be done through your database management system or hosting control panel."; + + switch (arguments.context.databaseType) { + case "MySQL": + local.rv.message &= chr(10) & chr(10) & "MySQL: CREATE DATABASE dbname CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"; + break; + case "PostgreSQL": + local.rv.message &= chr(10) & chr(10) & "PostgreSQL: CREATE DATABASE dbname WITH ENCODING='UTF8';"; + break; + case "SQLServer": + local.rv.message &= chr(10) & chr(10) & "SQL Server: CREATE DATABASE dbname;"; + break; + } + } + return local.rv; + } + + public struct function dbDrop(required struct context, required struct params) { + return { + success = false, + message = "Database dropping must be done through your database management system or hosting control panel for safety reasons." + }; + } + + public struct function dbReset(required struct context, required struct params) { + local.rv = {}; + try { + // Get all tables + local.tables = []; + if (arguments.context.databaseType == "H2") { + local.tablesQuery = new Query(); + local.tablesQuery.setDatasource(application.wheels.dataSourceName); + local.tablesQuery.setSQL("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'TABLE' AND TABLE_SCHEMA = 'PUBLIC' AND TABLE_NAME != 'SCHEMAINFO'"); + local.tables = local.tablesQuery.execute().getResult(); + } else { + local.tablesQuery = new Query(); + local.tablesQuery.setDatasource(application.wheels.dataSourceName); + local.tablesQuery.setSQL("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME != 'schemainfo'"); + local.tables = local.tablesQuery.execute().getResult(); + } + + // Drop all tables except schemainfo + for (local.table in local.tables) { + local.dropQuery = new Query(); + local.dropQuery.setDatasource(application.wheels.dataSourceName); + local.dropQuery.setSQL("DROP TABLE #local.table.TABLE_NAME#"); + local.dropQuery.execute(); + } + + // Reset migration version to 0 + local.resetQuery = new Query(); + local.resetQuery.setDatasource(application.wheels.dataSourceName); + local.resetQuery.setSQL("UPDATE schemainfo SET version = '0'"); + local.resetQuery.execute(); + + local.rv.success = true; + local.rv.message = "Database reset successfully. All tables dropped and migration version reset to 0."; + } catch (any e) { + local.rv.success = false; + local.rv.message = "Error resetting database: " & e.message; + } + return local.rv; + } + + public struct function dbSetup(required struct context, required struct params) { + // Setup database (migrate + optional seed). Composes seeding through a + // direct helper call — never by re-entering the dispatcher (issue #2959). + local.rv = {success = true, message = "Database setup: "}; + + try { + local.migrateResult = arguments.context.migrator.migrateToLatest(); + local.rv.message &= "Migrations completed. "; + + if (structKeyExists(arguments.params, "seed") && arguments.params.seed) { + local.seedParams = Duplicate(arguments.params); + local.seedParams.count = StructKeyExists(arguments.params, "seedCount") + ? val(arguments.params.seedCount) : 10; + local.seedResult = $runDbSeed(params = local.seedParams, context = arguments.context); + local.setupMessage = local.rv.message; + StructAppend(local.rv, local.seedResult, true); + local.rv.message = local.setupMessage & local.seedResult.message; + if (!local.seedResult.success) { + local.rv.success = false; + } + } + } catch (any e) { + local.rv.success = false; + local.rv.message &= "Migration failed: " & e.message & ". "; + } + return local.rv; + } + + public struct function dbDump(required struct context, required struct params) { + local.rv = {success = false, dump = ""}; + + // For H2, generate a dump directly. + if (arguments.context.databaseType == "H2") { + try { + local.dumpQuery = new Query(); + local.dumpQuery.setDatasource(application.wheels.dataSourceName); + local.dumpQuery.setSQL("SCRIPT SIMPLE"); + local.dumpResult = local.dumpQuery.execute().getResult(); + + local.sqlDump = ""; + for (local.row in local.dumpResult) { + local.sqlDump &= local.row.SCRIPT & ";" & chr(10); + } + + local.rv.success = true; + local.rv.dump = local.sqlDump; + local.rv.message = "Database dump generated successfully. Use --output parameter to save to file."; + + // If output file specified, save it. The path is canonicalized + // and confined to the application root (SEC-5). + if (structKeyExists(arguments.params, "output")) { + local.outputFile = arguments.context.host.$cliResolveDumpPath(arguments.params.output); + if (Len(local.outputFile)) { + fileWrite(local.outputFile, local.sqlDump); + local.rv.message = "Database dump saved to: " & arguments.params.output; + } else { + local.rv.success = false; + local.rv.message = "Invalid output path: the dump file must resolve inside the application root."; + } + } + } catch (any e) { + local.rv.message = "Error generating dump: " & e.message; + } + } else { + local.rv.message = "Database dump functionality requires command-line tools specific to your database system."; + switch (arguments.context.databaseType) { + case "MySQL": + local.rv.message &= " Use: mysqldump -u [username] -p [database] > backup.sql"; + break; + case "PostgreSQL": + local.rv.message &= " Use: pg_dump -U [username] [database] > backup.sql"; + break; + case "SQLServer": + local.rv.message &= " Use SQL Server Management Studio or: sqlcmd -S [server] -d [database] -Q 'BACKUP DATABASE...'"; + break; + } + } + return local.rv; + } + + public struct function dbRestore(required struct context, required struct params) { + local.rv = { + success = false, + message = "Database restore functionality requires command-line tools specific to your database system." + }; + + switch (arguments.context.databaseType) { + case "MySQL": + local.rv.message &= " Use: mysql -u [username] -p [database] < backup.sql"; + break; + case "PostgreSQL": + local.rv.message &= " Use: psql -U [username] [database] < backup.sql"; + break; + case "SQLServer": + local.rv.message &= " Use SQL Server Management Studio or: sqlcmd -S [server] -d [database] -i backup.sql"; + break; + case "H2": + local.rv.message &= " Use: RUNSCRIPT FROM 'backup.sql' in H2 console"; + break; + } + return local.rv; + } + + public struct function dbShell(required struct context, required struct params) { + local.rv = {success = false}; + + // For H2, provide specific information about accessing the console. + if (arguments.context.databaseType == "H2") { + local.rv.message = "H2 Database Console Access:" & chr(10); + local.rv.message &= chr(10) & "Option 1: Web Console" & chr(10); + local.rv.message &= "The H2 web console may be available at the /h2-console path of your application." & chr(10); + local.rv.message &= "URL: http://localhost:[your-port]/h2-console" & chr(10); + local.rv.message &= "JDBC URL: " & application.wheels.dataSourceName & chr(10); + + try { + local.dbinfo = new Query(); + local.dbinfo.setDatasource(application.wheels.dataSourceName); + local.dbinfo.setSQL("SELECT DATABASE() as dbname, USER() as dbuser"); + local.dbResult = local.dbinfo.execute().getResult(); + if (local.dbResult.recordCount) { + local.rv.message &= "Database: " & local.dbResult.dbname & chr(10); + local.rv.message &= "User: " & local.dbResult.dbuser & chr(10); + } + } catch (any e) { + // Ignore errors getting extra info + } + + local.rv.message &= chr(10) & "Option 2: Command Line" & chr(10); + local.rv.message &= "java -cp [path-to-h2.jar] org.h2.tools.Shell" & chr(10); + } else { + local.rv.message = "Database shell access requires command-line tools. "; + switch (arguments.context.databaseType) { + case "MySQL": + local.rv.message &= "Use: mysql -u [username] -p [database]"; + break; + case "PostgreSQL": + local.rv.message &= "Use: psql -U [username] [database]"; + break; + case "SQLServer": + local.rv.message &= "Use: sqlcmd -S [server] -d [database] -U [username]"; + break; + } + } + return local.rv; + } + + // ── Job worker commands ───────────────────────────────────────────── + + public struct function jobsProcessNext(required struct context, required struct params) { + local.rv = {}; + try { + local.worker = new wheels.JobWorker(); + local.jobQueues = structKeyExists(arguments.params, "queues") ? arguments.params.queues : ""; + local.jobTimeout = structKeyExists(arguments.params, "timeout") ? val(arguments.params.timeout) : 300; + local.jobResult = local.worker.processNext(queues = local.jobQueues, timeout = local.jobTimeout); + local.rv.success = true; + local.rv.jobResult = local.jobResult; + local.rv.message = local.jobResult.skipped ? "No jobs available" : "Processed job #local.jobResult.jobId#"; + } catch (any e) { + local.rv.success = false; + local.rv.message = "Error processing job: " & e.message; + } + return local.rv; + } + + public struct function jobsStatus(required struct context, required struct params) { + local.rv = {}; + try { + local.worker = new wheels.JobWorker(); + local.jobQueue = structKeyExists(arguments.params, "queue") ? arguments.params.queue : ""; + local.rv.success = true; + local.rv.stats = local.worker.getStats(queue = local.jobQueue); + local.rv.message = "Queue statistics retrieved"; + } catch (any e) { + local.rv.success = false; + local.rv.message = "Error getting status: " & e.message; + } + return local.rv; + } + + public struct function jobsRetry(required struct context, required struct params) { + local.rv = {}; + try { + local.worker = new wheels.JobWorker(); + local.jobQueue = structKeyExists(arguments.params, "queue") ? arguments.params.queue : ""; + local.jobLimit = structKeyExists(arguments.params, "limit") ? val(arguments.params.limit) : 0; + local.retryCount = local.worker.retryFailed(queue = local.jobQueue, limit = local.jobLimit); + local.rv.success = true; + local.rv.retried = local.retryCount; + local.rv.message = "Retried #local.retryCount# failed job(s)"; + } catch (any e) { + local.rv.success = false; + local.rv.message = "Error retrying jobs: " & e.message; + } + return local.rv; + } + + public struct function jobsPurge(required struct context, required struct params) { + local.rv = {}; + try { + local.worker = new wheels.JobWorker(); + local.jobQueue = structKeyExists(arguments.params, "queue") ? arguments.params.queue : ""; + local.purgeStatus = structKeyExists(arguments.params, "status") ? arguments.params.status : "completed"; + local.purgeDays = structKeyExists(arguments.params, "days") ? val(arguments.params.days) : 7; + local.purgeCount = local.worker.purge(status = local.purgeStatus, days = local.purgeDays, queue = local.jobQueue); + local.rv.success = true; + local.rv.purged = local.purgeCount; + local.rv.message = "Purged #local.purgeCount# #local.purgeStatus# job(s)"; + } catch (any e) { + local.rv.success = false; + local.rv.message = "Error purging jobs: " & e.message; + } + return local.rv; + } + + public struct function jobsMonitor(required struct context, required struct params) { + local.rv = {}; + try { + local.worker = new wheels.JobWorker(); + local.jobQueue = structKeyExists(arguments.params, "queue") ? arguments.params.queue : ""; + local.minutes = structKeyExists(arguments.params, "minutes") ? val(arguments.params.minutes) : 60; + local.rv.success = true; + local.rv.monitor = local.worker.getMonitorData(queue = local.jobQueue, minutes = local.minutes); + local.rv.stats = local.worker.getStats(queue = local.jobQueue); + local.timeouts = local.worker.checkTimeouts(); + if (local.timeouts > 0) { + local.rv.timeoutsRecovered = local.timeouts; + } + local.rv.message = "Monitor data retrieved"; + } catch (any e) { + local.rv.success = false; + local.rv.message = "Error getting monitor data: " & e.message; + } + return local.rv; + } + + // ── Internal ──────────────────────────────────────────────────────── + + /** + * Seed orchestration, shared by `dbSeed` and `dbSetup`. Returns a struct + * with {success, mode, message, ...mode-specific fields} merged into the + * response envelope by the caller. + */ + private struct function $runDbSeed(required struct params, required struct context) { + var result = {success = true, mode = "auto", message = ""}; + var sp = arguments.params; + var requestedMode = structKeyExists(sp, "mode") ? sp.mode : "auto"; + var environment = structKeyExists(sp, "environment") ? sp.environment : arguments.context.host.get("environment"); + result.mode = requestedMode; + + try { + var useConvention = false; + if (requestedMode == "convention") { + useConvention = true; + } else if (requestedMode == "generate") { + useConvention = false; + } else if (structKeyExists(application.wheels, "seeder") && application.wheels.seeder.hasSeedFiles()) { + useConvention = true; + } + + if (useConvention) { + result.mode = "convention"; + var seeder = application.wheels.seeder; + var conventionResult = seeder.runSeeds(environment = environment); + result.success = conventionResult.success; + result.message = conventionResult.message; + result.environment = environment; + result.totalCreated = conventionResult.totalCreated; + result.totalSkipped = conventionResult.totalSkipped; + if (structKeyExists(conventionResult, "totalFailed")) { + result.totalFailed = conventionResult.totalFailed; + } + result.results = conventionResult.results; + if (structKeyExists(conventionResult, "detail")) { + result.detail = conventionResult.detail; + } + } else { + // Generate mode delegates to Seeder.generateSeeds() (#3082). + var count = structKeyExists(sp, "count") ? val(sp.count) : 10; + var modelsArg = structKeyExists(sp, "models") ? sp.models : ""; + var generateSeeder = structKeyExists(application.wheels, "seeder") + ? application.wheels.seeder + : CreateObject("component", "wheels.Seeder").init(); + var generateResult = generateSeeder.generateSeeds(models = modelsArg, count = count); + StructAppend(result, generateResult, true); + } + } catch (any e) { + result.success = false; + result.message = "Error during database seeding: " & e.message; + } + + return result; + } + +} diff --git a/vendor/wheels/public/views/cli.cfm b/vendor/wheels/public/views/cli.cfm index 2b063c909e..de28b1b28f 100644 --- a/vendor/wheels/public/views/cli.cfm +++ b/vendor/wheels/public/views/cli.cfm @@ -67,794 +67,33 @@ try { if (Len(local.cliCommand)) { data.command = local.cliCommand; - switch (local.cliCommand) { - case "createMigration": - if (StructKeyExists(request.wheels.params, "migrationPrefix") && Len(request.wheels.params.migrationPrefix)) { - data.message = migrator.createMigration( - request.wheels.params.migrationName, - request.wheels.params.templateName, - request.wheels.params.migrationPrefix - ); - } else { - data.message = migrator.createMigration( - request.wheels.params.migrationName, - request.wheels.params.templateName - ); - } - break; - case "migrateTo": - if (StructKeyExists(request.wheels.params, "version")) { - data.message = migrator.migrateTo(request.wheels.params.version); - } - break; - case "migrateToLatest": - data.message = migrator.migrateToLatest(); - break; - case "migrateUp": - // Walk the migration list (sorted ascending by version) and - // migrate to the first pending version after the current one. - // `migrateTo` handles the actual transaction + status update. - local.targetVersion = ""; - for (local.m in data.migrations) { - if (local.m.status != "migrated" && local.m.version > data.currentVersion) { - local.targetVersion = local.m.version; - break; - } - } - if (Len(local.targetVersion)) { - data.message = migrator.migrateTo(local.targetVersion); - } else { - data.message = "No pending migrations. Database is at version #data.currentVersion#."; - } - break; - case "migrateDown": - // Walk the list in reverse to find the migration immediately - // below the current version, then migrate down to it. If the - // current version is the first applied migration, target "0" - // (rolls back the only migration). - local.targetVersion = "0"; - for (local.i = ArrayLen(data.migrations); local.i >= 1; local.i--) { - local.m = data.migrations[local.i]; - if (local.m.version < data.currentVersion && local.m.status == "migrated") { - local.targetVersion = local.m.version; - break; - } - } - if (data.currentVersion == "0") { - data.message = "Database is at version 0; nothing to roll back."; - } else { - data.message = migrator.migrateTo(local.targetVersion); - } - break; - case "renameSystemTables": - // F15 Phase 2: opt-in rename of legacy c_o_r_e_* system tables. - // Returns the full result struct (success/renamed/skipped/errors/sql) - // rather than a flat message — the CLI decodes and prints each field. - local.dryRun = (StructKeyExists(request.wheels.params, "dryRun") && request.wheels.params.dryRun == "true"); - data.renameResult = migrator.renameSystemTables(dryRun = local.dryRun); - data.success = data.renameResult.success; - if (Len(data.renameResult.skipped)) { - data.message = data.renameResult.skipped; - } else if (ArrayLen(data.renameResult.renamed)) { - data.message = "Renamed: " & ArrayToList(data.renameResult.renamed, "; "); - } else if (local.dryRun && ArrayLen(data.renameResult.sql)) { - data.message = "Dry run — SQL that would execute:" & Chr(10) & ArrayToList(data.renameResult.sql, ";" & Chr(10)) & ";"; - } - break; - case "diff": - try { - local.autoMigrator = CreateObject("component", "wheels.migrator.AutoMigrator"); - local.options = {}; - - // Parse hints from URL: hints={"renames":{"old":"new"}} as JSON-encoded string - if (StructKeyExists(request.wheels.params, "hints") && Len(request.wheels.params.hints)) { - local.decodedHints = DeserializeJSON(request.wheels.params.hints); - if (IsStruct(local.decodedHints)) { - StructAppend(local.options, local.decodedHints, true); - } - } - if (StructKeyExists(request.wheels.params, "threshold") && Len(request.wheels.params.threshold) && IsNumeric(request.wheels.params.threshold)) { - local.options.heuristicThreshold = request.wheels.params.threshold; - } - - if (StructKeyExists(request.wheels.params, "modelName") && Len(request.wheels.params.modelName)) { - local.diffResult = local.autoMigrator.diff(request.wheels.params.modelName, local.options); - - // Optionally write the migration file - local.migrationWritten = ""; - if (StructKeyExists(request.wheels.params, "write") && request.wheels.params.write == "true") { - local.migName = StructKeyExists(request.wheels.params, "name") && Len(request.wheels.params.name) ? request.wheels.params.name : ""; - local.autoMigrator.writeMigration(local.diffResult, local.migName); - local.migrationWritten = "written"; - } - - data.success = true; - data.model = local.diffResult; - data.migrationWritten = local.migrationWritten; - } else { - // diffAll path - local.diffAllResult = local.autoMigrator.diffAll(local.options); - - local.written = []; - if (StructKeyExists(request.wheels.params, "write") && request.wheels.params.write == "true") { - for (local.m in local.diffAllResult) { - local.autoMigrator.writeMigration(local.diffAllResult[local.m], ""); - ArrayAppend(local.written, local.m); - } - } - - data.success = true; - data.models = local.diffAllResult; - data.migrationsWritten = local.written; - } - } catch (any e) { - data.success = false; - data.error = e.type; - data.message = e.message; - } - break; - case "redoMigration": - if (StructKeyExists(request.wheels.params, "version")) { - local.redoVersion = request.wheels.params.version; - } else { - local.redoVersion = data.lastVersion; - } - data.message = migrator.redoMigration(local.redoVersion); - break; - case "info": - // Build a human-readable status block. The migrations list - // is rendered by Migrator.$buildInfoOutput() so the logic - // is unit-testable without exercising the HTTP dispatcher. - // Issue #2780 surfaced orphan versions (DB rows with no - // matching file) — those are rendered with a [?] marker - // and an explanatory footer. - local.lines = []; - ArrayAppend(local.lines, "Datasource: " & data.datasource); - ArrayAppend(local.lines, "Database type: " & data.databaseType); - for (local.line in migrator.$buildInfoOutput()) { - ArrayAppend(local.lines, local.line); - } - data.message = ArrayToList(local.lines, Chr(10)); - break; - case "doctor": - // Comprehensive migrator health diagnostic. Returns a struct - // describing orphans, pending, and applied counts. See #2780. - // Plan 3: orphansWithMeta exposes the peer's migration name - // + apply timestamp when the schema is enriched. - local.report = migrator.doctor(); - data.healthy = local.report.healthy; - data.currentVersion = local.report.currentVersion; - data.orphans = local.report.orphans; - data.orphansWithMeta = local.report.orphansWithMeta; - data.pending = local.report.pending; - data.summary = local.report.summary; - local.docLines = []; - ArrayAppend(local.docLines, local.report.message); - ArrayAppend(local.docLines, ""); - ArrayAppend(local.docLines, " Datasource: " & data.datasource); - ArrayAppend(local.docLines, " Database type: " & data.databaseType); - ArrayAppend(local.docLines, " Current version: " & (Len(local.report.currentVersion) ? local.report.currentVersion : "0")); - ArrayAppend(local.docLines, " Total migrations: " & local.report.summary.total); - ArrayAppend(local.docLines, " applied: " & local.report.summary.applied); - ArrayAppend(local.docLines, " pending: " & local.report.summary.pending); - if (local.report.summary.orphan > 0) { - ArrayAppend(local.docLines, " orphan: " & local.report.summary.orphan & " (" & ArrayToList(local.report.orphans, ", ") & ")"); - } - if (ArrayLen(local.report.pending) > 0) { - ArrayAppend(local.docLines, ""); - ArrayAppend(local.docLines, "Pending local migrations:"); - for (local.v in local.report.pending) { - ArrayAppend(local.docLines, " [ ] " & local.v); - } - } - if (ArrayLen(local.report.orphansWithMeta) > 0) { - ArrayAppend(local.docLines, ""); - ArrayAppend(local.docLines, "Orphan versions (no matching file):"); - for (local.o in local.report.orphansWithMeta) { - local.orphanLine = " [?] " & local.o.version; - if (Len(local.o.name)) { - local.orphanLine &= " " & local.o.name; - } - if (Len(local.o.appliedAt)) { - local.orphanLine &= " (applied " & local.o.appliedAt & ")"; - } - ArrayAppend(local.docLines, local.orphanLine); - } - ArrayAppend(local.docLines, ""); - ArrayAppend(local.docLines, "Resolve: `wheels migrate forget --yes` to remove an orphan row,"); - ArrayAppend(local.docLines, " or pull the peer's migration file via git."); - } - data.message = ArrayToList(local.docLines, Chr(10)); - break; - case "forgetVersion": - // Remove a row from wheels_migrator_versions without running - // down(). Refuses if the version has a matching local file. - local.versionArg = request.wheels.params.version ?: ""; - if (!Len(local.versionArg)) { - data.success = false; - data.message = "Missing required argument: version. Usage: wheels migrate forget "; - break; - } - local.forgetResult = migrator.forgetVersion(local.versionArg); - data.success = local.forgetResult.success; - data.removed = local.forgetResult.removed; - data.message = local.forgetResult.message; - break; - case "pretendVersion": - // Record a version as applied without running up(). Refuses - // if already applied or if no local file matches. - local.pretendArg = request.wheels.params.version ?: ""; - if (!Len(local.pretendArg)) { - data.success = false; - data.message = "Missing required argument: version. Usage: wheels migrate pretend "; - break; - } - local.pretendResult = migrator.pretendVersion(local.pretendArg); - data.success = local.pretendResult.success; - data.recorded = local.pretendResult.recorded; - data.message = local.pretendResult.message; - break; - - // Database commands - case "dbStatus": - // Return migration status straight from the migrator's own - // status field — see Public.cfc::$cliFormatMigrationStatus() - // for why the old version-comparison heuristic was wrong. - // Reuses the list discovered in the preamble instead of - // running discovery a second time. - local.statusReport = $cliFormatMigrationStatus(data.migrations); - data.success = true; - data.migrations = local.statusReport.migrations; - data.summary = local.statusReport.summary; - break; - - case "dbVersion": - // Return current database version - data.success = true; - data.version = data.currentVersion; - data.message = "Current database version: " & data.currentVersion; - break; - - case "dbRollback": - // Rollback database - local.steps = structKeyExists(request.wheels.params, "steps") ? request.wheels.params.steps : 1; - local.targetVersion = ""; - - // Find target version based on steps. Reuses the list - // discovered in the preamble instead of re-discovering. - // Filter on tracked status, not version <= current: on a shared - // dev DB a peer-applied version above your latest local file - // made the version heuristic count pending/orphan rows as - // applied, so `steps=N` rolled back fewer real migrations - // (same P3 fix dbStatus got in #2947; #2977). - local.appliedMigrations = []; - for (local.migration in data.migrations) { - if (local.migration.status == "migrated") { - arrayAppend(local.appliedMigrations, local.migration); - } - } - - if (arrayLen(local.appliedMigrations) >= local.steps) { - local.targetIndex = arrayLen(local.appliedMigrations) - local.steps; - if (local.targetIndex > 0) { - local.targetVersion = local.appliedMigrations[local.targetIndex].version; - } else { - local.targetVersion = "0"; - } - } - - if (len(local.targetVersion)) { - data.message = migrator.migrateTo(local.targetVersion); - data.success = true; - } else { - data.success = false; - data.message = "No migrations to rollback"; - } - break; - - case "dbSchema": - // Export database schema - data.success = true; - data.schema = {}; - - try { - // Use database adapter to get schema information - local.adapter = application.wheels.dataAdapter; - data.schema.databaseType = data.databaseType; - data.schema.tables = []; - - // Get all tables - local.tables = []; - if (data.databaseType == "H2") { - // H2 specific query - local.tablesQuery = new Query(); - local.tablesQuery.setDatasource(application.wheels.dataSourceName); - local.tablesQuery.setSQL("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'TABLE' AND TABLE_SCHEMA = 'PUBLIC'"); - local.tables = local.tablesQuery.execute().getResult(); - } else { - // Generic INFORMATION_SCHEMA query - local.tablesQuery = new Query(); - local.tablesQuery.setDatasource(application.wheels.dataSourceName); - local.tablesQuery.setSQL("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'"); - local.tables = local.tablesQuery.execute().getResult(); - } - - for (local.table in local.tables) { - local.tableInfo = { - name = local.table.TABLE_NAME, - columns = [] - }; - - // Get columns for each table - local.columns = new Query(); - local.columns.setDatasource(application.wheels.dataSourceName); - if (data.databaseType == "H2") { - local.columns.setSQL("SELECT COLUMN_NAME, TYPE_NAME as DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = :tableName AND TABLE_SCHEMA = 'PUBLIC'"); - } else { - local.columns.setSQL("SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = :tableName"); - } - local.columns.addParam(name="tableName", value=local.table.TABLE_NAME, cfsqltype="cf_sql_varchar"); - local.columnResult = local.columns.execute().getResult(); - - for (local.column in local.columnResult) { - arrayAppend(local.tableInfo.columns, { - name = local.column.COLUMN_NAME, - type = local.column.DATA_TYPE, - nullable = local.column.IS_NULLABLE, - default = local.column.COLUMN_DEFAULT ?: "" - }); - } - - arrayAppend(data.schema.tables, local.tableInfo); - } - } catch (any e) { - data.success = false; - data.message = "Error retrieving schema: " & e.message; - } - break; - - case "introspect": - data.success = false; - if (!structKeyExists(request.wheels.params, "model") || !len(request.wheels.params.model)) { - data.message = "Missing required parameter: model"; - break; - } - - try { - local.modelName = request.wheels.params.model; - local.modelInstance = model(local.modelName); - local.classData = local.modelInstance.$classData(); - - data.model = local.modelName; - data.tableName = local.classData.tableName ?: lCase(local.modelName) & "s"; - data.primaryKey = local.classData.keys ?: "id"; - - data.columns = []; - if (structKeyExists(local.classData, "properties")) { - for (local.propName in local.classData.properties) { - local.prop = local.classData.properties[local.propName]; - local.colInfo = { - name: local.propName, - type: local.prop.type ?: "string", - primaryKey: listFindNoCase(data.primaryKey, local.propName) > 0 - }; - if (structKeyExists(local.prop, "maxLength") && val(local.prop.maxLength) > 0) { - local.colInfo.maxLength = local.prop.maxLength; - } - if (right(local.propName, 2) == "Id" && len(local.propName) > 2) { - local.colInfo.foreignKey = true; - local.refName = left(local.propName, len(local.propName) - 2); - local.colInfo.referencedModel = uCase(left(local.refName, 1)) & mid(local.refName, 2, len(local.refName) - 1); - } - arrayAppend(data.columns, local.colInfo); - } - } - - data.associations = []; - if (structKeyExists(local.classData, "associations")) { - for (local.assocName in local.classData.associations) { - local.assoc = local.classData.associations[local.assocName]; - local.assocModelName = local.assoc.modelName ?: local.assocName; - local.assocModelName = uCase(left(local.assocModelName, 1)) & mid(local.assocModelName, 2, len(local.assocModelName) - 1); - arrayAppend(data.associations, { - type: local.assoc.type ?: "belongsTo", - name: local.assocName, - modelName: local.assocModelName - }); - } - } - - data.success = true; - data.message = "Model introspected successfully"; - } catch (any e) { - data.message = "Error introspecting model: " & e.message; - } - break; - - case "dbSeed": - // The seed orchestration lives in the page-level - // runDbSeed() UDF below. Generate mode delegates to - // wheels.Seeder.generateSeeds(). Extracted so `dbSetup` - // can compose seeding without re-entering the dispatcher - // (issue ##2959). - local.seedResult = runDbSeed(request.wheels.params); - StructAppend(data, local.seedResult, true); - break; - - case "routes": - // Return application routes. Routes live at application.wheels.routes - // (the convention every other case in this file uses); the previous - // `application[application.wheels.appKey]` indirection was broken - // because `appKey` is a function, not a property. - data.success = true; - data.routes = []; - if (structKeyExists(application, "wheels") && structKeyExists(application.wheels, "routes")) { - for (local.route in application.wheels.routes) { - local.routeInfo = { - name = structKeyExists(local.route, "name") ? local.route.name : "", - pattern = structKeyExists(local.route, "pattern") ? local.route.pattern : "", - controller = structKeyExists(local.route, "controller") ? local.route.controller : "", - action = structKeyExists(local.route, "action") ? local.route.action : "", - methods = structKeyExists(local.route, "methods") ? local.route.methods : "GET" - }; - arrayAppend(data.routes, local.routeInfo); - } - } - break; - - case "dbCreate": - // Create database - data.success = false; - - // For H2, we can provide helpful info and ensure schema table exists - if (data.databaseType == "H2") { - try { - // Check if schemainfo table exists - local.checkQuery = new Query(); - local.checkQuery.setDatasource(application.wheels.dataSourceName); - local.checkQuery.setSQL("SELECT COUNT(*) as cnt FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'SCHEMAINFO'"); - local.checkResult = local.checkQuery.execute().getResult(); - - if (local.checkResult.cnt == 0) { - // Create schemainfo table - local.createQuery = new Query(); - local.createQuery.setDatasource(application.wheels.dataSourceName); - local.createQuery.setSQL("CREATE TABLE IF NOT EXISTS schemainfo (version VARCHAR(25) DEFAULT '0')"); - local.createQuery.execute(); - - // Insert initial version - local.insertQuery = new Query(); - local.insertQuery.setDatasource(application.wheels.dataSourceName); - local.insertQuery.setSQL("INSERT INTO schemainfo (version) VALUES ('0')"); - local.insertQuery.execute(); - - data.message = "H2 database initialized successfully with schema tracking table."; - } else { - data.message = "H2 database already exists and is properly configured."; - } - data.success = true; - } catch (any e) { - data.message = "H2 database exists but error checking schema: " & e.message; - data.success = true; // Still mark as success since H2 auto-creates - } - } else { - data.message = "Database creation must be done through your database management system or hosting control panel."; - - // Provide helpful commands for common databases - switch(data.databaseType) { - case "MySQL": - data.message &= chr(10) & chr(10) & "MySQL: CREATE DATABASE dbname CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"; - break; - case "PostgreSQL": - data.message &= chr(10) & chr(10) & "PostgreSQL: CREATE DATABASE dbname WITH ENCODING='UTF8';"; - break; - case "SQLServer": - data.message &= chr(10) & chr(10) & "SQL Server: CREATE DATABASE dbname;"; - break; - } - } - break; - - case "dbDrop": - // Drop database - data.success = false; - data.message = "Database dropping must be done through your database management system or hosting control panel for safety reasons."; - break; - - case "dbReset": - // Reset database (drop all tables and re-run migrations) - try { - // Get all tables - local.tables = []; - if (data.databaseType == "H2") { - local.tablesQuery = new Query(); - local.tablesQuery.setDatasource(application.wheels.dataSourceName); - local.tablesQuery.setSQL("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'TABLE' AND TABLE_SCHEMA = 'PUBLIC' AND TABLE_NAME != 'SCHEMAINFO'"); - local.tables = local.tablesQuery.execute().getResult(); - } else { - local.tablesQuery = new Query(); - local.tablesQuery.setDatasource(application.wheels.dataSourceName); - local.tablesQuery.setSQL("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME != 'schemainfo'"); - local.tables = local.tablesQuery.execute().getResult(); - } - - // Drop all tables except schemainfo - for (local.table in local.tables) { - local.dropQuery = new Query(); - local.dropQuery.setDatasource(application.wheels.dataSourceName); - local.dropQuery.setSQL("DROP TABLE #local.table.TABLE_NAME#"); - local.dropQuery.execute(); - } - - // Reset migration version to 0 - local.resetQuery = new Query(); - local.resetQuery.setDatasource(application.wheels.dataSourceName); - local.resetQuery.setSQL("UPDATE schemainfo SET version = '0'"); - local.resetQuery.execute(); - - data.success = true; - data.message = "Database reset successfully. All tables dropped and migration version reset to 0."; - } catch (any e) { - data.success = false; - data.message = "Error resetting database: " & e.message; - } - break; - - case "dbSetup": - // Setup database (create + migrate + seed) - data.success = true; - data.message = "Database setup: "; - - try { - local.migrateResult = migrator.migrateToLatest(); - data.message &= "Migrations completed. "; - - if (structKeyExists(request.wheels.params, "seed") && request.wheels.params.seed) { - // Compose seeding through a direct UDF call — - // the legacy path mutated `request.wheels.params` - // and re-included `cli.cfm`, which rebuilt the - // envelope from scratch and silently discarded - // the "Migrations completed." string we just set - // (issue ##2959). Merge the seed result on top - // of `data` while preserving the dbSetup envelope - // (command, prefixed message, combined success). - local.seedParams = Duplicate(request.wheels.params); - local.seedParams.count = StructKeyExists(request.wheels.params, "seedCount") - ? val(request.wheels.params.seedCount) : 10; - local.seedResult = runDbSeed(local.seedParams); - local.setupMessage = data.message; - StructAppend(data, local.seedResult, true); - data.command = "dbSetup"; - data.message = local.setupMessage & local.seedResult.message; - if (!local.seedResult.success) { - data.success = false; - } - } - } catch (any e) { - data.success = false; - data.message &= "Migration failed: " & e.message & ". "; - } - break; - - case "dbDump": - // Dump database - data.success = false; - data.dump = ""; - - // For H2, we can generate a dump directly - if (data.databaseType == "H2") { - try { - local.dumpQuery = new Query(); - local.dumpQuery.setDatasource(application.wheels.dataSourceName); - local.dumpQuery.setSQL("SCRIPT SIMPLE"); - local.dumpResult = local.dumpQuery.execute().getResult(); - - // Build SQL dump - local.sqlDump = ""; - for (local.row in local.dumpResult) { - local.sqlDump &= local.row.SCRIPT & ";" & chr(10); - } - - data.success = true; - data.dump = local.sqlDump; - data.message = "Database dump generated successfully. Use --output parameter to save to file."; - - // If output file specified, save it. The path is - // canonicalized and confined to the application root - // (2026-06-09 review SEC-5) — `../` traversal would - // otherwise make this an arbitrary-location file write. - if (structKeyExists(request.wheels.params, "output")) { - local.outputFile = $cliResolveDumpPath(request.wheels.params.output); - if (Len(local.outputFile)) { - fileWrite(local.outputFile, local.sqlDump); - data.message = "Database dump saved to: " & request.wheels.params.output; - } else { - data.success = false; - data.message = "Invalid output path: the dump file must resolve inside the application root."; - } - } - - } catch (any e) { - data.message = "Error generating dump: " & e.message; - } - } else { - // Provide database-specific guidance for other systems - data.message = "Database dump functionality requires command-line tools specific to your database system."; - switch(data.databaseType) { - case "MySQL": - data.message &= " Use: mysqldump -u [username] -p [database] > backup.sql"; - break; - case "PostgreSQL": - data.message &= " Use: pg_dump -U [username] [database] > backup.sql"; - break; - case "SQLServer": - data.message &= " Use SQL Server Management Studio or: sqlcmd -S [server] -d [database] -Q 'BACKUP DATABASE...'"; - break; - } - } - break; - - case "dbRestore": - // Restore database - data.success = false; - data.message = "Database restore functionality requires command-line tools specific to your database system."; - - // Provide database-specific guidance - switch(data.databaseType) { - case "MySQL": - data.message &= " Use: mysql -u [username] -p [database] < backup.sql"; - break; - case "PostgreSQL": - data.message &= " Use: psql -U [username] [database] < backup.sql"; - break; - case "SQLServer": - data.message &= " Use SQL Server Management Studio or: sqlcmd -S [server] -d [database] -i backup.sql"; - break; - case "H2": - data.message &= " Use: RUNSCRIPT FROM 'backup.sql' in H2 console"; - break; - } - break; - - case "dbShell": - // Database shell - data.success = false; - - // For H2, provide specific information about accessing the console - if (data.databaseType == "H2") { - data.message = "H2 Database Console Access:" & chr(10); - data.message &= chr(10) & "Option 1: Web Console" & chr(10); - data.message &= "The H2 web console may be available at the /h2-console path of your application." & chr(10); - data.message &= "URL: http://localhost:[your-port]/h2-console" & chr(10); - data.message &= "JDBC URL: " & application.wheels.dataSourceName & chr(10); - - // Try to get connection info - try { - local.dbinfo = new Query(); - local.dbinfo.setDatasource(application.wheels.dataSourceName); - local.dbinfo.setSQL("SELECT DATABASE() as dbname, USER() as dbuser"); - local.dbResult = local.dbinfo.execute().getResult(); - if (local.dbResult.recordCount) { - data.message &= "Database: " & local.dbResult.dbname & chr(10); - data.message &= "User: " & local.dbResult.dbuser & chr(10); - } - } catch (any e) { - // Ignore errors getting extra info - } - - data.message &= chr(10) & "Option 2: Command Line" & chr(10); - data.message &= "java -cp [path-to-h2.jar] org.h2.tools.Shell" & chr(10); - - // NOTE: an earlier revision tried to execute - // request.wheels.params.command as SQL here, but that - // param is always the literal dispatch value "dbShell", - // so the branch executed "dbShell" as SQL, always threw, - // and clobbered the help text above with an error - // (2026-06-09 review P1). An SQL pass-through would also - // need the POST + reload-password gate; use the console - // (`wheels console`) for ad-hoc statements instead. - } else { - // Provide database-specific guidance - data.message = "Database shell access requires command-line tools. "; - switch(data.databaseType) { - case "MySQL": - data.message &= "Use: mysql -u [username] -p [database]"; - break; - case "PostgreSQL": - data.message &= "Use: psql -U [username] [database]"; - break; - case "SQLServer": - data.message &= "Use: sqlcmd -S [server] -d [database] -U [username]"; - break; - } - } - break; - - // ── Job Worker Commands ────────────────────────────────────── - - case "jobsProcessNext": - // Process the next available job (used by `wheels jobs work`) - try { - local.worker = new wheels.JobWorker(); - local.jobQueues = structKeyExists(request.wheels.params, "queues") ? request.wheels.params.queues : ""; - local.jobTimeout = structKeyExists(request.wheels.params, "timeout") ? val(request.wheels.params.timeout) : 300; - local.jobResult = local.worker.processNext(queues=local.jobQueues, timeout=local.jobTimeout); - data.success = true; - data.jobResult = local.jobResult; - data.message = local.jobResult.skipped ? "No jobs available" : "Processed job #local.jobResult.jobId#"; - } catch (any e) { - data.success = false; - data.message = "Error processing job: " & e.message; - } - break; - - case "jobsStatus": - // Get queue statistics (used by `wheels jobs status`) - try { - local.worker = new wheels.JobWorker(); - local.jobQueue = structKeyExists(request.wheels.params, "queue") ? request.wheels.params.queue : ""; - data.success = true; - data.stats = local.worker.getStats(queue=local.jobQueue); - data.message = "Queue statistics retrieved"; - } catch (any e) { - data.success = false; - data.message = "Error getting status: " & e.message; - } - break; - - case "jobsRetry": - // Retry failed jobs (used by `wheels jobs retry`) - try { - local.worker = new wheels.JobWorker(); - local.jobQueue = structKeyExists(request.wheels.params, "queue") ? request.wheels.params.queue : ""; - local.jobLimit = structKeyExists(request.wheels.params, "limit") ? val(request.wheels.params.limit) : 0; - local.retryCount = local.worker.retryFailed(queue=local.jobQueue, limit=local.jobLimit); - data.success = true; - data.retried = local.retryCount; - data.message = "Retried #local.retryCount# failed job(s)"; - } catch (any e) { - data.success = false; - data.message = "Error retrying jobs: " & e.message; - } - break; - - case "jobsPurge": - // Purge old jobs (used by `wheels jobs purge`) - try { - local.worker = new wheels.JobWorker(); - local.jobQueue = structKeyExists(request.wheels.params, "queue") ? request.wheels.params.queue : ""; - local.purgeStatus = structKeyExists(request.wheels.params, "status") ? request.wheels.params.status : "completed"; - local.purgeDays = structKeyExists(request.wheels.params, "days") ? val(request.wheels.params.days) : 7; - local.purgeCount = local.worker.purge(status=local.purgeStatus, days=local.purgeDays, queue=local.jobQueue); - data.success = true; - data.purged = local.purgeCount; - data.message = "Purged #local.purgeCount# #local.purgeStatus# job(s)"; - } catch (any e) { - data.success = false; - data.message = "Error purging jobs: " & e.message; - } - break; - - case "jobsMonitor": - // Get monitoring data (used by `wheels jobs monitor`) - try { - local.worker = new wheels.JobWorker(); - local.jobQueue = structKeyExists(request.wheels.params, "queue") ? request.wheels.params.queue : ""; - local.minutes = structKeyExists(request.wheels.params, "minutes") ? val(request.wheels.params.minutes) : 60; - data.success = true; - data.monitor = local.worker.getMonitorData(queue=local.jobQueue, minutes=local.minutes); - data.stats = local.worker.getStats(queue=local.jobQueue); - local.timeouts = local.worker.checkTimeouts(); - if (local.timeouts > 0) { - data.timeoutsRecovered = local.timeouts; - } - data.message = "Monitor data retrieved"; - } catch (any e) { - data.success = false; - data.message = "Error getting monitor data: " & e.message; - } - break; + // Dispatch to the CliBridge service (issue #2959). The 44-case switch + // that used to live here is now one allowlist-gated method per command + // in wheels.public.CliBridge — individually unit-testable, and the + // dispatcher never spreads query-string keys into function arguments. + // `context` carries the preamble-computed envelope values plus a `host` + // reference (this Public instance) so handlers can reach framework + // primitives (model(), get(), $cliFormatMigrationStatus(), + // $cliResolveDumpPath()). An unrecognized command stays a silent no-op, + // preserving the legacy default-less switch behavior. + local.bridge = $cliBridge(); + if (local.bridge.handles(local.cliCommand)) { + local.cliContext = { + host = this, + migrator = migrator, + datasource = data.datasource, + databaseType = data.databaseType, + currentVersion = data.currentVersion, + lastVersion = data.lastVersion, + migrations = data.migrations + }; + local.cliResult = local.bridge.dispatch( + command = local.cliCommand, + context = local.cliContext, + params = request.wheels.params + ); + StructAppend(data, local.cliResult, true); } } } catch (any e) { @@ -868,68 +107,6 @@ try { data.message = e.message & ': ' & e.detail; data.messages = data.message; } - -// Seed orchestration extracted from the `dbSeed` switch case so that -// `dbSetup` can compose seeding through a direct call instead of the -// legacy recursive cfinclude (issue ##2959). Returns a struct with -// {success, mode, message, ...mode-specific fields} that the caller -// merges into the response envelope via StructAppend. -function runDbSeed(struct seedParams = {}) { - var result = {success = true, mode = "auto", message = ""}; - var sp = arguments.seedParams; - var requestedMode = structKeyExists(sp, "mode") ? sp.mode : "auto"; - var environment = structKeyExists(sp, "environment") ? sp.environment : get("environment"); - result.mode = requestedMode; - - try { - var useConvention = false; - if (requestedMode == "convention") { - useConvention = true; - } else if (requestedMode == "generate") { - useConvention = false; - } else if (structKeyExists(application.wheels, "seeder") && application.wheels.seeder.hasSeedFiles()) { - useConvention = true; - } - - if (useConvention) { - result.mode = "convention"; - var seeder = application.wheels.seeder; - var conventionResult = seeder.runSeeds(environment = environment); - result.success = conventionResult.success; - result.message = conventionResult.message; - result.environment = environment; - result.totalCreated = conventionResult.totalCreated; - result.totalSkipped = conventionResult.totalSkipped; - if (structKeyExists(conventionResult, "totalFailed")) { - result.totalFailed = conventionResult.totalFailed; - } - result.results = conventionResult.results; - if (structKeyExists(conventionResult, "detail")) { - result.detail = conventionResult.detail; - } - } else { - // Generate mode delegates to Seeder.generateSeeds(), which fixes - // both #3082 defects: it iterates $classData().properties as the - // STRUCT it is (the old inline loop treated it as an array of - // property structs and threw on every model), and it reports - // overall success=false when any model fails — so the CLI surfaces - // a non-zero exit instead of printing "Seeding completed." (#3082). - var count = structKeyExists(sp, "count") ? val(sp.count) : 10; - var modelsArg = structKeyExists(sp, "models") ? sp.models : ""; - var generateSeeder = structKeyExists(application.wheels, "seeder") - ? application.wheels.seeder - : CreateObject("component", "wheels.Seeder").init(); - var generateResult = generateSeeder.generateSeeds(models = modelsArg, count = count); - StructAppend(result, generateResult, true); - } - } catch (any e) { - result.success = false; - result.message = "Error during database seeding: " & e.message; - } - - return result; -} - #SerializeJSON(data)# diff --git a/vendor/wheels/rocketunit_tests/Application.cfc b/vendor/wheels/rocketunit_tests/Application.cfc index cce8012284..2506679156 100644 --- a/vendor/wheels/rocketunit_tests/Application.cfc +++ b/vendor/wheels/rocketunit_tests/Application.cfc @@ -10,7 +10,10 @@ component { // Put variables we just need internally inside a wheels struct. this.wheels = {}; - this.wheels.rootPath = GetDirectoryFromPath(GetBaseTemplatePath()); + // Anchor to this file's directory (matches this.webrootDir below), not the + // requested base template's, so rootPath stays stable under subfolder + // bootstrap (issue #3025/#2887). + this.wheels.rootPath = GetDirectoryFromPath(GetCurrentTemplatePath()); this.webrootDir = getDirectoryFromPath( getCurrentTemplatePath() ); this.appDir = getCanonicalPath("_assets"); diff --git a/vendor/wheels/rocketunit_tests/Test.cfc b/vendor/wheels/rocketunit_tests/Test.cfc deleted file mode 100644 index 213670c91c..0000000000 --- a/vendor/wheels/rocketunit_tests/Test.cfc +++ /dev/null @@ -1,37 +0,0 @@ -component extends="wheels.Test" { - - /* - * Executes once before the test suite runs. - * Populates the test database on reload or if the authors table does not exist. - */ - function beforeAll() { - application.$$$wheels = duplicate(application.wheels); - local.tables = $dbinfo(datasource = application.wheels.dataSourceName, type = "tables"); - local.tableList = ValueList(local.tables.table_name); - local.populate = StructKeyExists(url, "populate") ? url.populate : true; - if (local.populate || !FindNoCase("c_o_r_e_authors", local.tableList)) { - include "populate.cfm"; - } - } - - /* - * Executes before every test case if called from the package via super.superSetup(). - */ - function setup() { - } - - /* - * Executes after every test case if called from the package via super.superTeardown(). - */ - function teardown() { - } - - /* - * Executes once after the test suite runs. - */ - function afterAll() { - application.wheels = application.$$$wheels; - structDelete(application, "$$$wheels"); - } - -} diff --git a/vendor/wheels/storage/S3Signer.cfc b/vendor/wheels/storage/S3Signer.cfc new file mode 100644 index 0000000000..2a3a3a4a99 --- /dev/null +++ b/vendor/wheels/storage/S3Signer.cfc @@ -0,0 +1,285 @@ +/** + * AWS Signature Version 4 signer for S3, implemented from scratch — no AWS SDK, + * no JARs. Generates presigned GET URLs (query-string auth) and Authorization + * headers (header auth) for arbitrary S3 requests issued via `cfhttp`. + * + * The crypto primitives are the same proven, cross-engine-green building blocks + * `wheels.auth.JwtService` relies on (SHA-256 hex hashing + a chained HMAC-SHA256 + * key-derivation), driven through `javax.crypto.Mac` so binary signing keys work + * identically on Lucee 5/6/7, Adobe CF 2018-2025, and BoxLang. + * + * Reference: AWS "Authenticating Requests: Using Query Parameters (AWS Signature + * Version 4)" and "...Using the Authorization Header...". + * + * Usage: + * var signer = new wheels.storage.S3Signer( + * accessKeyId="AKIA…", secretAccessKey="…", region="us-east-1", bucket="my-bucket" + * ); + * var url = signer.presignGetUrl(key="reports/q3.pdf", expiresIn=300); + * + * [section: Storage] + * [category: Core] + */ +component output="false" { + + /** + * @accessKeyId AWS access key id. + * @secretAccessKey AWS secret access key. + * @region AWS region (e.g. "us-east-1"). + * @bucket S3 bucket name. + * @endpoint Override the host (e.g. for S3-compatible stores). Empty => derive from bucket+region. + * @usePathStyle When true, addresses as host/bucket/key rather than bucket.host/key. + */ + public S3Signer function init( + required string accessKeyId, + required string secretAccessKey, + required string region, + required string bucket, + string endpoint = "", + boolean usePathStyle = false + ) { + variables.accessKeyId = arguments.accessKeyId; + variables.secretAccessKey = arguments.secretAccessKey; + variables.region = arguments.region; + variables.bucket = arguments.bucket; + variables.usePathStyle = arguments.usePathStyle; + variables.service = "s3"; + + if (Len(arguments.endpoint)) { + variables.host = arguments.endpoint; + } else if (arguments.usePathStyle) { + variables.host = "s3." & arguments.region & ".amazonaws.com"; + } else { + variables.host = arguments.bucket & ".s3." & arguments.region & ".amazonaws.com"; + } + + variables.javaSystem = CreateObject("java", "java.lang.System"); + return this; + } + + /** + * Build a presigned GET URL for an object key. + * + * @key Object key (path-like; slashes preserved). + * @expiresIn Seconds until the link expires (default 300, max 604800 per SigV4). + * @contentDisposition Optional response-content-disposition override S3 will echo. + * @amzDate Optional ISO8601 basic timestamp ("yyyymmddTHHnnssZ"). Defaults to now (UTC). Overridable for deterministic tests. + */ + public string function presignGetUrl( + required string key, + numeric expiresIn = 300, + string contentDisposition = "", + string amzDate = "" + ) { + local.amzDate = Len(arguments.amzDate) ? arguments.amzDate : $amzNow(); + local.dateStamp = Left(local.amzDate, 8); + local.credentialScope = local.dateStamp & "/" & variables.region & "/" & variables.service & "/aws4_request"; + + // Canonical URI: path-style prefixes the bucket; virtual-hosted does not. + local.canonicalUri = variables.usePathStyle + ? "/" & $uriEncodePath(variables.bucket & "/" & arguments.key) + : "/" & $uriEncodePath(arguments.key); + + // Canonical query string — keys must be sorted by their encoded name. + local.params = { + "X-Amz-Algorithm" = "AWS4-HMAC-SHA256", + "X-Amz-Credential" = variables.accessKeyId & "/" & local.credentialScope, + "X-Amz-Date" = local.amzDate, + "X-Amz-Expires" = arguments.expiresIn, + "X-Amz-SignedHeaders" = "host" + }; + if (Len(arguments.contentDisposition)) { + local.params["response-content-disposition"] = arguments.contentDisposition; + } + local.canonicalQuery = $buildCanonicalQuery(local.params); + + local.canonicalHeaders = "host:" & variables.host & Chr(10); + local.signedHeaders = "host"; + local.payloadHash = "UNSIGNED-PAYLOAD"; + + local.canonicalRequest = "GET" & Chr(10) + & local.canonicalUri & Chr(10) + & local.canonicalQuery & Chr(10) + & local.canonicalHeaders & Chr(10) + & local.signedHeaders & Chr(10) + & local.payloadHash; + + local.signature = $signString(local.canonicalRequest, local.amzDate, local.dateStamp, local.credentialScope); + + local.scheme = "https://"; + return local.scheme & variables.host & local.canonicalUri & "?" & local.canonicalQuery + & "&X-Amz-Signature=" & local.signature; + } + + /** + * Sign an arbitrary S3 request, returning the headers (incl. Authorization) + * a caller adds to a `cfhttp` invocation. Used for put/get/delete/exists. + * + * @method HTTP verb. + * @key Object key. + * @payload Request body (binary or string); empty for GET/DELETE/HEAD. + * @amzDate Optional deterministic timestamp override. + * @return Struct of header name => value to attach to the request. + */ + public struct function signedHeaders( + required string method, + required string key, + any payload = "", + string amzDate = "" + ) { + local.amzDate = Len(arguments.amzDate) ? arguments.amzDate : $amzNow(); + local.dateStamp = Left(local.amzDate, 8); + local.credentialScope = local.dateStamp & "/" & variables.region & "/" & variables.service & "/aws4_request"; + + local.payloadHash = $sha256Hex(arguments.payload); + + local.canonicalUri = variables.usePathStyle + ? "/" & $uriEncodePath(variables.bucket & "/" & arguments.key) + : "/" & $uriEncodePath(arguments.key); + + // Headers signed for header-auth: host, x-amz-content-sha256, x-amz-date (sorted). + local.canonicalHeaders = "host:" & variables.host & Chr(10) + & "x-amz-content-sha256:" & local.payloadHash & Chr(10) + & "x-amz-date:" & local.amzDate & Chr(10); + local.signedHeaderList = "host;x-amz-content-sha256;x-amz-date"; + + local.canonicalRequest = UCase(arguments.method) & Chr(10) + & local.canonicalUri & Chr(10) + & "" & Chr(10) + & local.canonicalHeaders & Chr(10) + & local.signedHeaderList & Chr(10) + & local.payloadHash; + + local.signature = $signString(local.canonicalRequest, local.amzDate, local.dateStamp, local.credentialScope); + + local.authorization = "AWS4-HMAC-SHA256 " + & "Credential=" & variables.accessKeyId & "/" & local.credentialScope & ", " + & "SignedHeaders=" & local.signedHeaderList & ", " + & "Signature=" & local.signature; + + return { + "Authorization" = local.authorization, + "x-amz-content-sha256" = local.payloadHash, + "x-amz-date" = local.amzDate, + "Host" = variables.host + }; + } + + /** + * The resolved request host (virtual-hosted or path-style endpoint). + */ + public string function getHost() { + return variables.host; + } + + /** + * RFC3986-encode an object key for use as a request path (forward slashes + * preserved). The wire URL must use the same encoding the canonical request + * signs, or S3 returns SignatureDoesNotMatch for keys with spaces / reserved + * characters. Lets the disk build request/url paths that stay byte-identical + * to what was signed. + * + * @key Object key. + */ + public string function encodeKey(required string key) { + return $uriEncodePath(arguments.key); + } + + // ---- internals -------------------------------------------------------- + + /** + * Produce the lowercase-hex SigV4 signature for a canonical request. + */ + private string function $signString( + required string canonicalRequest, + required string amzDate, + required string dateStamp, + required string credentialScope + ) { + local.stringToSign = "AWS4-HMAC-SHA256" & Chr(10) + & arguments.amzDate & Chr(10) + & arguments.credentialScope & Chr(10) + & $sha256Hex(arguments.canonicalRequest); + + local.signingKey = $signingKey(arguments.dateStamp); + return LCase(BinaryEncode($hmac(local.signingKey, local.stringToSign), "hex")); + } + + /** + * Derive the SigV4 signing key: HMAC chain seeded with "AWS4"+secret. + */ + private binary function $signingKey(required string dateStamp) { + local.kSecret = CharsetDecode("AWS4" & variables.secretAccessKey, "UTF-8"); + local.kDate = $hmac(local.kSecret, arguments.dateStamp); + local.kRegion = $hmac(local.kDate, variables.region); + local.kService = $hmac(local.kRegion, variables.service); + return $hmac(local.kService, "aws4_request"); + } + + /** + * HMAC-SHA256 with a binary key, returning raw bytes. Uses javax.crypto.Mac + * directly so successive rounds can key off the previous round's binary + * output — the built-in HMac() takes only string keys. + */ + private binary function $hmac(required binary key, required string message) { + local.mac = CreateObject("java", "javax.crypto.Mac").getInstance("HmacSHA256"); + local.keySpec = CreateObject("java", "javax.crypto.spec.SecretKeySpec").init(arguments.key, "HmacSHA256"); + local.mac.init(local.keySpec); + return local.mac.doFinal(CharsetDecode(arguments.message, "UTF-8")); + } + + /** + * Lowercase hex SHA-256 of a string or binary payload. + */ + private string function $sha256Hex(required any content) { + if (IsBinary(arguments.content)) { + return LCase(Hash(arguments.content, "SHA-256")); + } + return LCase(Hash(arguments.content, "SHA-256", "UTF-8")); + } + + /** + * Build a sorted, RFC3986-encoded canonical query string from a struct. + */ + private string function $buildCanonicalQuery(required struct params) { + local.keys = StructKeyArray(arguments.params); + // SigV4 sorts by raw byte order of the encoded key name; for our fixed + // ASCII parameter names a case-sensitive text sort is byte-identical. + ArraySort(local.keys, "text"); + local.pairs = []; + for (local.k in local.keys) { + ArrayAppend(local.pairs, $uriEncodeSegment(local.k) & "=" & $uriEncodeSegment(arguments.params[local.k])); + } + return ArrayToList(local.pairs, "&"); + } + + /** + * RFC3986 encode a single value (slashes ARE encoded). Built on + * java.net.URLEncoder with the AWS-required fix-ups so it is byte-identical + * across engines. + */ + private string function $uriEncodeSegment(required any value) { + local.encoder = CreateObject("java", "java.net.URLEncoder"); + local.encoded = local.encoder.encode(ToString(arguments.value), "UTF-8"); + local.encoded = Replace(local.encoded, "+", "%20", "all"); + local.encoded = Replace(local.encoded, "*", "%2A", "all"); + local.encoded = Replace(local.encoded, "%7E", "~", "all"); + return local.encoded; + } + + /** + * RFC3986 encode an object key path, preserving forward slashes. + */ + private string function $uriEncodePath(required string key) { + return Replace($uriEncodeSegment(arguments.key), "%2F", "/", "all"); + } + + /** + * Current UTC time as an ISO8601 basic timestamp ("yyyymmddTHHnnssZ"). + */ + private string function $amzNow() { + local.utc = DateConvert("local2utc", Now()); + return DateFormat(local.utc, "yyyymmdd") & "T" & TimeFormat(local.utc, "HHmmss") & "Z"; + } + +} diff --git a/vendor/wheels/storage/StorageManager.cfc b/vendor/wheels/storage/StorageManager.cfc new file mode 100644 index 0000000000..51dce0059b --- /dev/null +++ b/vendor/wheels/storage/StorageManager.cfc @@ -0,0 +1,90 @@ +/** + * Resolves named storage disks from configuration and caches them. + * + * Mirrors Laravel's filesystem manager and AdonisJS Drive: configuration names + * each disk and assigns it a `driver` ("local" or "s3"); application code asks + * for a disk by name (or the default) and gets a uniform + * `wheels.interfaces.StorageDiskInterface` back. Register the manager as a + * singleton in `config/services.cfm` and expose it via a `storage()` helper. + * + * Config shape (set in config/settings.cfm): + * set(storage = { + * default = "local", + * disks = { + * local = { driver="local", root="/storage/uploads", urlPrefix="/uploads" }, + * s3 = { driver="s3", bucket="…", region="…", accessKeyId="…", secretAccessKey="…" } + * } + * }); + * + * [section: Storage] + * [category: Core] + */ +component output="false" { + + variables.drivers = { + "local" = "wheels.storage.drivers.LocalDisk", + "s3" = "wheels.storage.drivers.S3Disk" + }; + + /** + * @config The storage config struct: { default, disks: { : { driver, … } } }. + */ + public StorageManager function init(struct config = {}) { + variables.config = arguments.config; + variables.default = StructKeyExists(arguments.config, "default") ? arguments.config.default : "local"; + variables.disks = StructKeyExists(arguments.config, "disks") ? arguments.config.disks : {}; + variables.resolved = {}; + return this; + } + + /** + * Resolve a disk by name (default disk when omitted). Disks are lazily + * instantiated and cached for the manager's lifetime. + * + * @name The configured disk name. + */ + public any function disk(string name = "") { + local.diskName = Len(arguments.name) ? arguments.name : variables.default; + + if (StructKeyExists(variables.resolved, local.diskName)) { + return variables.resolved[local.diskName]; + } + + if (!StructKeyExists(variables.disks, local.diskName)) { + throw( + type = "Wheels.Storage.UnknownDisk", + message = "No storage disk named [#local.diskName#] is configured.", + extendedInfo = "Configured disks: #StructKeyList(variables.disks)#." + ); + } + + local.diskConfig = variables.disks[local.diskName]; + local.driverName = LCase(StructKeyExists(local.diskConfig, "driver") ? local.diskConfig.driver : ""); + if (!StructKeyExists(variables.drivers, local.driverName)) { + throw( + type = "Wheels.Storage.UnknownDriver", + message = "Disk [#local.diskName#] uses unknown driver [#local.driverName#].", + extendedInfo = "Known drivers: #StructKeyList(variables.drivers)#." + ); + } + + local.instance = CreateObject("component", variables.drivers[local.driverName]).init(config = local.diskConfig); + variables.resolved[local.diskName] = local.instance; + return local.instance; + } + + /** + * The name of the default disk. + */ + public string function getDefaultDiskName() { + return variables.default; + } + + /** + * Names of all configured disks. + */ + public array function diskNames() { + return StructKeyArray(variables.disks); + } + +} diff --git a/vendor/wheels/storage/drivers/LocalDisk.cfc b/vendor/wheels/storage/drivers/LocalDisk.cfc new file mode 100644 index 0000000000..c084c52143 --- /dev/null +++ b/vendor/wheels/storage/drivers/LocalDisk.cfc @@ -0,0 +1,193 @@ +/** + * Local filesystem storage disk. + * + * Stores objects under a configured `root` directory and exposes them through + * a `urlPrefix` (served by the application). Signed URLs carry an HMAC token + * over the key + expiry — there is no native filesystem presigning, so, like + * every framework that ships a local disk (Rails DiskController, Laravel + * `serve`, AdonisJS `serveFiles`), the application is expected to verify the + * token before streaming the file. + * + * [section: Storage] + * [category: Driver] + */ +component implements="wheels.interfaces.StorageDiskInterface" output="false" { + + /** + * @config Disk config: { root (required), urlPrefix="", signingKey="" }. + */ + public LocalDisk function init(required struct config) { + if (!StructKeyExists(arguments.config, "root") || !Len(arguments.config.root)) { + throw( + type = "Wheels.Storage.InvalidConfiguration", + message = "Local disk requires a non-empty 'root' directory." + ); + } + variables.root = $normalizeDir(arguments.config.root); + variables.urlPrefix = StructKeyExists(arguments.config, "urlPrefix") ? arguments.config.urlPrefix : ""; + variables.signingKey = StructKeyExists(arguments.config, "signingKey") ? arguments.config.signingKey : ""; + return this; + } + + public any function put(required string key, required any content, string contentType = "", string visibility = "") { + local.path = $resolve(arguments.key); + $ensureParentDir(local.path); + // Write bytes, never a string. Adobe 2025's FileWrite() appends a + // trailing LF (0x0A) when handed a simple value — storing "hello world" + // put 12 bytes on disk, so `get()` no longer round-tripped what `put()` + // was given, and any binary payload came back corrupted by one byte. + // Lucee 6/7, BoxLang and Adobe 2023 write the string verbatim, so this + // only ever surfaced on the adobe2025 matrix legs (#3302). The binary + // overload has no line-ending behaviour on any engine. + local.payload = IsBinary(arguments.content) ? arguments.content : CharsetDecode(arguments.content, "utf-8"); + FileWrite(local.path, local.payload); + return arguments.key; + } + + public any function get(required string key) { + local.path = $resolve(arguments.key); + if (!FileExists(local.path)) { + throw( + type = "Wheels.Storage.NotFound", + message = "No object stored at key [#arguments.key#]." + ); + } + return FileReadBinary(local.path); + } + + public boolean function exists(required string key) { + return FileExists($resolve(arguments.key)); + } + + public boolean function delete(required string key) { + local.path = $resolve(arguments.key); + if (FileExists(local.path)) { + FileDelete(local.path); + return true; + } + return false; + } + + public string function url(required string key) { + return $joinUrl(variables.urlPrefix, arguments.key); + } + + public string function signedUrl(required string key, numeric expiresIn = 300, string contentDisposition = "") { + if (!Len(variables.signingKey)) { + throw( + type = "Wheels.Storage.MissingSigningKey", + message = "Local disk signedUrl() requires a 'signingKey' in the disk config." + ); + } + local.expiresAt = $epochSeconds() + arguments.expiresIn; + local.token = $sign($signaturePayload(arguments.key, local.expiresAt, arguments.contentDisposition)); + local.base = $joinUrl(variables.urlPrefix, arguments.key); + local.qs = "expires=" & local.expiresAt & "&signature=" & local.token; + if (Len(arguments.contentDisposition)) { + local.qs &= "&disposition=" & $uriEncode(arguments.contentDisposition); + } + return local.base & "?" & local.qs; + } + + /** + * Verify a signed-URL token for the application's serving route. + * + * @key The requested key. + * @expires The epoch-seconds expiry carried in the URL. + * @signature The token carried in the URL. + * @contentDisposition The disposition carried in the URL (bound into the token). + */ + public boolean function verifySignature(required string key, required numeric expires, required string signature, string contentDisposition = "") { + if (!Len(variables.signingKey)) { + return false; + } + if ($epochSeconds() > arguments.expires) { + return false; + } + local.expected = $sign($signaturePayload(arguments.key, arguments.expires, arguments.contentDisposition)); + return $secureEquals(local.expected, arguments.signature); + } + + // ---- internals -------------------------------------------------------- + + private string function $sign(required string message) { + return LCase(HMac(arguments.message, variables.signingKey, "HMACSHA256", "UTF-8")); + } + + /** + * Canonical string the signed-URL HMAC covers. Binding the disposition in + * means a holder of a valid URL cannot alter the served Content-Disposition. + * Empty disposition reproduces the legacy "key|expires" payload, so URLs + * signed without one still verify. + */ + private string function $signaturePayload(required string key, required numeric expires, string contentDisposition = "") { + local.payload = arguments.key & "|" & arguments.expires; + if (Len(arguments.contentDisposition)) { + local.payload &= "|" & arguments.contentDisposition; + } + return local.payload; + } + + /** + * Length-independent equality for two hex tokens. Unlike CompareNoCase it + * does not short-circuit on the first differing character, so it does not + * leak how much of the token matched through timing. Both inputs are the + * fixed-width lowercase-hex output of $sign(), so a length mismatch can only + * be a forged/garbage token — comparing false there is correct. + */ + private boolean function $secureEquals(required string a, required string b) { + local.x = LCase(arguments.a); + local.y = LCase(arguments.b); + if (Len(local.x) != Len(local.y)) { + return false; + } + local.diff = 0; + for (local.i = 1; local.i <= Len(local.x); local.i++) { + local.diff = BitOr(local.diff, BitXor(Asc(Mid(local.x, local.i, 1)), Asc(Mid(local.y, local.i, 1)))); + } + return local.diff == 0; + } + + private string function $resolve(required string key) { + // Reject traversal — a key must stay inside root. + local.clean = Replace(arguments.key, "\", "/", "all"); + if (Find("..", local.clean)) { + throw( + type = "Wheels.Storage.InvalidKey", + message = "Storage key [#arguments.key#] must not contain '..'." + ); + } + return variables.root & "/" & local.clean; + } + + private string function $normalizeDir(required string dir) { + local.d = Replace(arguments.dir, "\", "/", "all"); + return REReplace(local.d, "/+$", ""); + } + + private void function $ensureParentDir(required string path) { + local.parent = GetDirectoryFromPath(arguments.path); + // Use java.io.File.mkdirs() rather than the Lucee-only DirectoryCreate + // recurse flag so directory creation behaves on every engine. + local.file = CreateObject("java", "java.io.File").init(local.parent); + if (!local.file.exists()) { + local.file.mkdirs(); + } + } + + private string function $joinUrl(required string prefix, required string key) { + local.p = REReplace(arguments.prefix, "/+$", ""); + local.k = REReplace(arguments.key, "^/+", ""); + return local.p & "/" & local.k; + } + + private string function $uriEncode(required string value) { + local.encoded = CreateObject("java", "java.net.URLEncoder").encode(arguments.value, "UTF-8"); + return Replace(local.encoded, "+", "%20", "all"); + } + + private numeric function $epochSeconds() { + return Int(CreateObject("java", "java.lang.System").currentTimeMillis() / 1000); + } + +} diff --git a/vendor/wheels/storage/drivers/S3Disk.cfc b/vendor/wheels/storage/drivers/S3Disk.cfc new file mode 100644 index 0000000000..7edc307bb3 --- /dev/null +++ b/vendor/wheels/storage/drivers/S3Disk.cfc @@ -0,0 +1,178 @@ +/** + * Amazon S3 (and S3-compatible) storage disk. + * + * Talks to S3 over plain `cfhttp` with from-scratch SigV4 request signing — + * no AWS SDK, no JARs (see `wheels.storage.S3Signer`). `url()` returns the + * object's public URL; `signedUrl()` returns a presigned, expiring GET URL. + * + * [section: Storage] + * [category: Driver] + */ +component implements="wheels.interfaces.StorageDiskInterface" output="false" { + + /** + * @config Disk config: { bucket, region, accessKeyId, secretAccessKey, + * visibility="private", endpoint="", usePathStyle=false, timeout=60 }. + */ + public S3Disk function init(required struct config) { + for (local.required in ["bucket", "region", "accessKeyId", "secretAccessKey"]) { + if (!StructKeyExists(arguments.config, local.required) || !Len(arguments.config[local.required])) { + throw( + type = "Wheels.Storage.InvalidConfiguration", + message = "S3 disk requires a non-empty '#local.required#'." + ); + } + } + variables.bucket = arguments.config.bucket; + variables.region = arguments.config.region; + variables.visibility = StructKeyExists(arguments.config, "visibility") ? arguments.config.visibility : "private"; + variables.usePathStyle = StructKeyExists(arguments.config, "usePathStyle") ? arguments.config.usePathStyle : false; + variables.endpoint = StructKeyExists(arguments.config, "endpoint") ? arguments.config.endpoint : ""; + variables.timeout = StructKeyExists(arguments.config, "timeout") ? arguments.config.timeout : 60; + + variables.signer = new wheels.storage.S3Signer( + accessKeyId = arguments.config.accessKeyId, + secretAccessKey = arguments.config.secretAccessKey, + region = arguments.config.region, + bucket = arguments.config.bucket, + endpoint = variables.endpoint, + usePathStyle = variables.usePathStyle + ); + return this; + } + + public any function put(required string key, required any content, string contentType = "application/octet-stream", string visibility = "") { + local.headers = variables.signer.signedHeaders(method = "PUT", key = arguments.key, payload = arguments.content); + local.result = $request(method = "PUT", key = arguments.key, headers = local.headers, body = arguments.content, contentType = arguments.contentType); + $assertSuccess(result = local.result, method = "PUT", key = arguments.key); + return arguments.key; + } + + public any function get(required string key) { + local.headers = variables.signer.signedHeaders(method = "GET", key = arguments.key); + local.result = $request(method = "GET", key = arguments.key, headers = local.headers, getAsBinary = true); + if (Val(local.result.statusCode) == 404) { + throw(type = "Wheels.Storage.NotFound", message = "No object stored at key [#arguments.key#]."); + } + $assertSuccess(result = local.result, method = "GET", key = arguments.key); + return local.result.fileContent; + } + + public boolean function exists(required string key) { + local.headers = variables.signer.signedHeaders(method = "HEAD", key = arguments.key); + local.result = $request(method = "HEAD", key = arguments.key, headers = local.headers); + local.code = Val(local.result.statusCode); + if (local.code >= 200 && local.code < 300) { + return true; + } + if (local.code == 404) { + return false; + } + // A connection failure or 5xx is NOT "the object is absent" — reporting + // false there would be a silent failure, so surface it instead. + throw( + type = "Wheels.Storage.RequestFailed", + message = "S3 HEAD failed for [#arguments.key#]: #$statusDetail(local.result)#." + ); + } + + public boolean function delete(required string key) { + local.headers = variables.signer.signedHeaders(method = "DELETE", key = arguments.key); + local.result = $request(method = "DELETE", key = arguments.key, headers = local.headers); + // S3 DELETE is idempotent — 2xx whether or not the object existed — but a + // connection failure or 5xx must not masquerade as a successful delete. + $assertSuccess(result = local.result, method = "DELETE", key = arguments.key); + return true; + } + + public string function url(required string key) { + return "https://" & variables.signer.getHost() & $objectPath(arguments.key); + } + + public string function signedUrl(required string key, numeric expiresIn = 300, string contentDisposition = "") { + return variables.signer.presignGetUrl( + key = arguments.key, + expiresIn = arguments.expiresIn, + contentDisposition = arguments.contentDisposition + ); + } + + // ---- internals -------------------------------------------------------- + + private string function $objectPath(required string key) { + local.k = REReplace(arguments.key, "^/+", ""); + // Encode through the signer so the wire path is byte-identical to the + // canonical path the SigV4 signature covers — otherwise S3 rejects keys + // containing spaces / reserved characters with SignatureDoesNotMatch. + // Mirrors the signer's canonicalUri (path-style prefixes the bucket). + return variables.usePathStyle + ? "/" & variables.signer.encodeKey(variables.bucket & "/" & local.k) + : "/" & variables.signer.encodeKey(local.k); + } + + /** + * Throw `Wheels.Storage.RequestFailed` unless the request returned a 2xx + * status. `cfhttp` does not set `throwOnError`, so a DNS/connection failure + * does not throw — it returns a non-numeric status (e.g. "Connection + * Failure") whose `Val()` is 0. The `< 200` guard catches that path too; + * without it a failed request would read as success and silently lose data. + */ + private void function $assertSuccess(required struct result, required string method, required string key) { + local.code = Val(arguments.result.statusCode); + if (local.code < 200 || local.code >= 300) { + throw( + type = "Wheels.Storage.RequestFailed", + message = "S3 #arguments.method# failed for [#arguments.key#]: #$statusDetail(arguments.result)#." + ); + } + } + + /** + * Human-readable status for error messages — the raw status line, or an + * explicit note when the request produced none (connection failure). + */ + private string function $statusDetail(required struct result) { + return Len(arguments.result.statusCode) ? arguments.result.statusCode : "no response (connection failure)"; + } + + /** + * Issue a signed cfhttp request. Headers are copied into a plain struct + * before being attached one-by-one — never `attributeCollection=arguments`, + * which Adobe CF 2023/2025 reject on built-in tags. + */ + private struct function $request( + required string method, + required string key, + required struct headers, + any body = "", + string contentType = "", + boolean getAsBinary = false + ) { + local.targetUrl = "https://" & variables.signer.getHost() & $objectPath(arguments.key); + local.hdrs = {}; + for (local.name in arguments.headers) { + local.hdrs[local.name] = arguments.headers[local.name]; + } + + local.httpResult = ""; + cfhttp(method = arguments.method, url = local.targetUrl, result = "local.httpResult", getAsBinary = (arguments.getAsBinary ? "yes" : "auto"), timeout = variables.timeout) { + for (local.name in local.hdrs) { + cfhttpparam(type = "header", name = local.name, value = local.hdrs[local.name]); + } + if (Len(arguments.contentType)) { + cfhttpparam(type = "header", name = "Content-Type", value = arguments.contentType); + } + if (!IsSimpleValue(arguments.body) || Len(arguments.body)) { + cfhttpparam(type = "body", value = arguments.body); + } + } + // Preserve the raw status line — callers extract the numeric code with + // Val() (0 for a non-numeric connection-failure status) and use the full + // string for diagnostics. + return { + statusCode = local.httpResult.statusCode ?: "", + fileContent = local.httpResult.fileContent ?: "" + }; + } + +} diff --git a/vendor/wheels/tests/_assets/controllers/Authorization.cfc b/vendor/wheels/tests/_assets/controllers/Authorization.cfc new file mode 100644 index 0000000000..e0692b9ffd --- /dev/null +++ b/vendor/wheels/tests/_assets/controllers/Authorization.cfc @@ -0,0 +1,16 @@ +component extends="Controller" { + + /** + * Overrides the authorization mixin's identity resolver — methods declared on + * the controller win over mixins in $integrateComponents(), which is also the + * documented app-side customization seam. Lets specs control the current user + * without touching the DI container or the session scope. + */ + public any function $currentUserForPolicy() { + if (StructKeyExists(request, "$policyTestUser")) { + return request.$policyTestUser; + } + return ""; + } + +} diff --git a/vendor/wheels/tests/_assets/controllers/SuperOverride.cfc b/vendor/wheels/tests/_assets/controllers/SuperOverride.cfc new file mode 100644 index 0000000000..6b6136731d --- /dev/null +++ b/vendor/wheels/tests/_assets/controllers/SuperOverride.cfc @@ -0,0 +1,13 @@ +component extends="wheels.Controller" { + + /** + * Overrides a framework view helper the way the "Overriding Core Methods" guide + * describes, then delegates to the framework original via the `super` + * convention. Before issue #3325 `superLinkTo` was never registered in + * controller/view context, so this threw at render time. + */ + public string function linkTo() { + return "wrapped:" & superLinkTo(argumentCollection = arguments); + } + +} diff --git a/vendor/wheels/tests/_assets/dispatch/TestFormatResolver.cfc b/vendor/wheels/tests/_assets/dispatch/TestFormatResolver.cfc new file mode 100644 index 0000000000..4bc3f7dfca --- /dev/null +++ b/vendor/wheels/tests/_assets/dispatch/TestFormatResolver.cfc @@ -0,0 +1,87 @@ +/** + * Helper extracted from app-runner.cfm so the output-format rule is + * unit-testable without spinning up an HTTP request. app-runner.cfm reads + * url.format, hands it to this resolver, and uses the returned reporter / + * contentType / rendersHtml / recognized fields to drive the response. + * + * Issue #3251 (item 1): the html / no-format branch historically emitted + * application/json for the app runner — a user opening + * `/wheels/app/tests?format=html` (or hitting the no-format default) in a + * browser got raw JSON instead of the TestBox-style HTML report the core + * runner (`/wheels/core/tests`) renders. resolveFormat() marks that branch + * rendersHtml=true so the app runner falls through to html.cfm with + * type="App" — a branch html.cfm already supports — exactly like the core + * runner falls through with type="Core". + * + * Recognized formats (case-insensitive, trimmed): html | json | txt | junit, + * plus the no-format default (no `format` key) which is treated as html. Any + * other value — an empty string or an arbitrary token like "xml" — resolves + * to recognized=false. The app runner emits nothing for an unrecognized + * format, preserving the historical behavior: html.cfm must NOT be rendered + * for an arbitrary url.format. Its dev-tools navigation + * (vendor/wheels/tests/_navigation.cfm) builds format-toggle links from + * url.format and the framework's response content-negotiation throws a 500 + * on Adobe when the format is unknown. + */ +component { + + variables.REPORTER_PACKAGE = "wheels.wheelstest.system.reports"; + + public struct function resolveFormat(required struct url) { + var htmlChoice = { + format: "html", + reporter: variables.REPORTER_PACKAGE & ".JSONReporter", + contentType: "text/html", + rendersHtml: true, + recognized: true + }; + + // No format key at all is the no-format default → HTML report. + if (!StructKeyExists(arguments.url, "format")) { + return htmlChoice; + } + + var format = LCase(Trim(arguments.url.format)); + + switch (format) { + case "html": + return htmlChoice; + case "json": + return { + format: "json", + reporter: variables.REPORTER_PACKAGE & ".JSONReporter", + contentType: "application/json", + rendersHtml: false, + recognized: true + }; + case "txt": + return { + format: "txt", + reporter: variables.REPORTER_PACKAGE & ".TextReporter", + contentType: "text/plain", + rendersHtml: false, + recognized: true + }; + case "junit": + return { + format: "junit", + reporter: variables.REPORTER_PACKAGE & ".ANTJUnitReporter", + contentType: "text/xml", + rendersHtml: false, + recognized: true + }; + default: + // Empty value or unrecognized token: not a known format. The app + // runner emits nothing, matching the historical behavior and + // avoiding an html.cfm render for an arbitrary url.format. + return { + format: format, + reporter: "", + contentType: "", + rendersHtml: false, + recognized: false + }; + } + } + +} diff --git a/vendor/wheels/tests/_assets/models/RefChild.cfc b/vendor/wheels/tests/_assets/models/RefChild.cfc new file mode 100644 index 0000000000..af3eb9211f --- /dev/null +++ b/vendor/wheels/tests/_assets/models/RefChild.cfc @@ -0,0 +1,16 @@ +/** + * Fixture for #3337: the child side of an association whose foreign key column uses the + * `_id` convention that `useUnderscoreReferenceColumns` makes the migrator emit. + * + * The association passes no `foreignKey`, so the default derivation has to resolve + * `refparent_id` on this model. Before #3337 it derived `refparentid` unconditionally and + * any `include=` threw `key [refparentid] doesn't exist`. + */ +component extends="Model" { + + function config() { + table("c_o_r_e_refchildren"); + belongsTo("refParent"); + } + +} diff --git a/vendor/wheels/tests/_assets/models/RefParent.cfc b/vendor/wheels/tests/_assets/models/RefParent.cfc new file mode 100644 index 0000000000..a94058ba78 --- /dev/null +++ b/vendor/wheels/tests/_assets/models/RefParent.cfc @@ -0,0 +1,15 @@ +/** + * Fixture for #3337: the parent side of an association whose foreign key column uses the + * `_id` convention that `useUnderscoreReferenceColumns` makes the migrator emit. + * + * Exercises the hasMany branch of the association foreign-key default, where the column + * lives on the ASSOCIATED model (`c_o_r_e_refchildren.refparent_id`). + */ +component extends="Model" { + + function config() { + table("c_o_r_e_refparents"); + hasMany("refChildren"); + } + +} diff --git a/vendor/wheels/tests/_assets/models/SuperOverride.cfc b/vendor/wheels/tests/_assets/models/SuperOverride.cfc new file mode 100644 index 0000000000..52b91a3b51 --- /dev/null +++ b/vendor/wheels/tests/_assets/models/SuperOverride.cfc @@ -0,0 +1,16 @@ +component extends="Model" { + + function config() { + table("c_o_r_e_posts"); + } + + /** + * Model-side counterpart to the controller fixture of the same name. The model + * layer has always registered `super`; this pins that so the parity the + * issue #3325 fix establishes cannot regress from either side. + */ + public string function columnNames() { + return "wrapped:" & superColumnNames(); + } + +} diff --git a/vendor/wheels/tests/_assets/models/Tenant.cfc b/vendor/wheels/tests/_assets/models/Tenant.cfc new file mode 100644 index 0000000000..b2ed308263 --- /dev/null +++ b/vendor/wheels/tests/_assets/models/Tenant.cfc @@ -0,0 +1,14 @@ +component extends="Model" { + + /* + * Exists purely to exercise the request-query-cache key collision guarded by + * requestQueryCacheTenantCollisionSpec (#3336): `Tenant` is the natural model name for the + * control-plane model in a database-per-tenant app, and CFML struct keys are case-insensitive, + * so it is the one model name that can alias onto framework-owned `request.wheels.tenant`. + * Backed by the existing authors fixture table so no populate.cfm changes are needed. + */ + function config() { + table("c_o_r_e_authors"); + } + +} diff --git a/vendor/wheels/tests/_assets/policies/AuthorPolicy.cfc b/vendor/wheels/tests/_assets/policies/AuthorPolicy.cfc new file mode 100644 index 0000000000..bdfc4a8fd1 --- /dev/null +++ b/vendor/wheels/tests/_assets/policies/AuthorPolicy.cfc @@ -0,0 +1,9 @@ +/** + * Test fixture policy for the Author model with NO overrides — every action and + * the scope inherit the DEFAULT-DENY behavior from the wheels.Policy base, so + * specs can pin the base-class contract through a real app-style policy. + */ +component extends="Policy" { + + +} diff --git a/vendor/wheels/tests/_assets/policies/Policy.cfc b/vendor/wheels/tests/_assets/policies/Policy.cfc new file mode 100644 index 0000000000..3220bcb330 --- /dev/null +++ b/vendor/wheels/tests/_assets/policies/Policy.cfc @@ -0,0 +1,8 @@ +/** + * Test fixture: app-level base policy stub, mirroring `app/policies/Policy.cfc` + * (which mirrors how `app/models/Model.cfc` extends `wheels.Model`). + */ +component extends="wheels.Policy" { + + +} diff --git a/vendor/wheels/tests/_assets/policies/PostPolicy.cfc b/vendor/wheels/tests/_assets/policies/PostPolicy.cfc new file mode 100644 index 0000000000..49ca09db9c --- /dev/null +++ b/vendor/wheels/tests/_assets/policies/PostPolicy.cfc @@ -0,0 +1,36 @@ +/** + * Test fixture policy for the Post model. Exercises the grant/deny surface of + * the authorization layer: + * - index: any authenticated user (guest denies) + * - show: everyone (including guests) + * - update: only the post's author + * - scope: authors see their own posts; guests see nothing (inherited default-deny) + * - publish (custom action): intentionally NOT defined — must deny + * - create/edit/delete/new: inherited default-deny from the base + */ +component extends="Policy" { + + public boolean function index() { + return IsStruct(variables.user) && !StructIsEmpty(variables.user); + } + + public boolean function show() { + return true; + } + + public boolean function update() { + return IsStruct(variables.user) + && StructKeyExists(variables.user, "id") + && IsObject(variables.record) + && StructKeyExists(variables.record, "authorId") + && variables.user.id == variables.record.authorId; + } + + public any function scope(required any collection) { + if (IsStruct(variables.user) && StructKeyExists(variables.user, "id")) { + return arguments.collection.where("authorid", variables.user.id); + } + return super.scope(arguments.collection); + } + +} diff --git a/vendor/wheels/tests/_assets/views/test/_groupRow.cfm b/vendor/wheels/tests/_assets/views/test/_grouprow.cfm similarity index 100% rename from vendor/wheels/tests/_assets/views/test/_groupRow.cfm rename to vendor/wheels/tests/_assets/views/test/_grouprow.cfm diff --git a/vendor/wheels/tests/app-runner.cfm b/vendor/wheels/tests/app-runner.cfm index 5cba3d6d51..5f01665631 100644 --- a/vendor/wheels/tests/app-runner.cfm +++ b/vendor/wheels/tests/app-runner.cfm @@ -114,48 +114,55 @@ bundlesDiscovered = local.bundlesDiscovered ); - if (!StructKeyExists(url, "format") || url.format == "html") { - result = testBox.run(reporter = "wheels.wheelstest.system.reports.JSONReporter"); - decoded = DeserializeJSON(result); - cfheader(statuscode = (decoded.totalFail > 0 || decoded.totalError > 0) ? 417 : 200); - // For the html case the framework runner falls through to html.cfm; - // for the app-runner we just emit the JSON in this branch too since - // app tests are typically requested over JSON (CLI/CI). Users hitting - // the URL in a browser still get a structured response they can read. - cfcontent(type="application/json"); - writeOutput(local.dirResolver.injectScopeMetadata( - resultJson = result, - scope = local.testScope, - bundlesDiscovered = local.bundlesDiscovered, - warnings = local.scopeWarnings - )); - } else if (url.format == "json") { - result = testBox.run(reporter = "wheels.wheelstest.system.reports.JSONReporter"); - decoded = DeserializeJSON(result); - if (decoded.totalFail > 0 || decoded.totalError > 0) { - if (!StructKeyExists(url, "cli") || !url.cli) { - cfheader(statuscode = 417); + // Resolve the output format (reporter + content type + whether to + // render an HTML report) through TestFormatResolver so the rule is + // unit-testable without an HTTP request (see AppRunnerTestFormatSpec, + // issue #3251). An unrecognized format resolves to recognized=false: + // the runner emits nothing, preserving the historical behavior. + local.fmtResolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + local.output = local.fmtResolver.resolveFormat(url); + + if (local.output.recognized) { + result = testBox.run(reporter = local.output.reporter); + + if (local.output.rendersHtml) { + // Render the TestBox-style HTML report for the html / no-format + // default, mirroring the core runner (vendor/wheels/tests/runner.cfm). + // html.cfm has a type="App" branch (package=tests.specs, + // route=testbox) built for exactly this. Previously this branch + // emitted raw JSON, so a user opening /wheels/app/tests?format=html + // in a browser got JSON instead of the report (issue #3251 item 1). + decoded = DeserializeJSON(result); + cfheader(statuscode = (decoded.totalFail > 0 || decoded.totalError > 0) ? 417 : 200); + type = "App"; + include "html.cfm"; + } else if (local.output.format == "json") { + decoded = DeserializeJSON(result); + if (decoded.totalFail > 0 || decoded.totalError > 0) { + if (!StructKeyExists(url, "cli") || !url.cli) { + cfheader(statuscode = 417); + } + } else { + cfheader(statuscode = 200); } + cfcontent(type = local.output.contentType); + cfheader(name="Access-Control-Allow-Origin", value="*"); + writeOutput(local.dirResolver.injectScopeMetadata( + resultJson = result, + scope = local.testScope, + bundlesDiscovered = local.bundlesDiscovered, + warnings = local.scopeWarnings + )); } else { - cfheader(statuscode = 200); + // txt / junit: emit the reporter output verbatim under the + // resolved content type. + cfcontent(type = local.output.contentType); + writeOutput(result); } - cfcontent(type="application/json"); - cfheader(name="Access-Control-Allow-Origin", value="*"); - writeOutput(local.dirResolver.injectScopeMetadata( - resultJson = result, - scope = local.testScope, - bundlesDiscovered = local.bundlesDiscovered, - warnings = local.scopeWarnings - )); - } else if (url.format == "txt") { - result = testBox.run(reporter = "wheels.wheelstest.system.reports.TextReporter"); - cfcontent(type = "text/plain"); - writeOutput(result); - } else if (url.format == "junit") { - result = testBox.run(reporter = "wheels.wheelstest.system.reports.ANTJUnitReporter"); - cfcontent(type = "text/xml"); - writeOutput(result); } + // Unrecognized format (empty value / unknown token): no output, and + // testBox is not run — html.cfm must not be rendered for an arbitrary + // url.format (it 500s on Adobe). Mirrors the pre-fix fall-through. } finally { // Restore the original datasource (via applyDataSource() so test-run cached model classes are invalidated). if (local.swappedDataSource) { diff --git a/vendor/wheels/tests/html.cfm b/vendor/wheels/tests/html.cfm index 9a89842e50..c38c2963f4 100644 --- a/vendor/wheels/tests/html.cfm +++ b/vendor/wheels/tests/html.cfm @@ -34,12 +34,18 @@ testResults.ok = (testResults.numFailures + testResults.numErrors) == 0; - // Recursive function to process nested suites - function processNestedSuites(suites, bundleName) { + // Recursive function to process nested suites. Declared as a variables-scoped + // function expression (not a named `function` declaration) so repeated + // includes of this template into the cached Public.cfc do not throw Adobe's + // "Routines cannot be declared more than once" — the same reason the core + // runner declares its helpers as closures (vendor/wheels/tests/runner.cfm). + // A named declaration in an included .cfm leaks into the component scope on + // Adobe and collides on the second request. See issue #3251. + variables.processNestedSuites = function(suites, bundleName) { for (suite in suites) { // Process nested suites first (deeper level) if (structKeyExists(suite, "suiteStats") && arrayLen(suite.suiteStats) > 0) { - processNestedSuites(suite.suiteStats, bundleName); + variables.processNestedSuites(suite.suiteStats, bundleName); } // Process individual specs in this suite @@ -108,10 +114,10 @@ arrayAppend(testResults.results, thisResult); } } - } + }; for (bundle in DeJsonResult.bundleStats) { - processNestedSuites(bundle.suiteStats, bundle.name); + variables.processNestedSuites(bundle.suiteStats, bundle.name); } failures = []; diff --git a/vendor/wheels/tests/populate.cfm b/vendor/wheels/tests/populate.cfm index 5193355665..2f0a5e9692 100644 --- a/vendor/wheels/tests/populate.cfm +++ b/vendor/wheels/tests/populate.cfm @@ -96,7 +96,7 @@ - + + +CREATE TABLE c_o_r_e_refparents +( + id #local.identityColumnType# + ,name varchar(50) + ,PRIMARY KEY(id) +) #local.storageEngine# + + + +CREATE TABLE c_o_r_e_refchildren +( + id #local.identityColumnType# + ,refparent_id #local.intColumnType# + ,PRIMARY KEY(id) +) #local.storageEngine# + + CREATE TABLE c_o_r_e_combikeys ( diff --git a/vendor/wheels/tests/runner.cfm b/vendor/wheels/tests/runner.cfm index 8e88cc8a9a..de7a61f934 100644 --- a/vendor/wheels/tests/runner.cfm +++ b/vendor/wheels/tests/runner.cfm @@ -49,6 +49,28 @@ application.wo.set(viewPath = AssetPath & "views") application.wo.set(modelPath = AssetPath & "models") application.wo.set(wheelsComponentPath = "/wheels") + // Isolated-app boot (issue #3374) mounts browser-fixture controllers + // onto controllerPath. Drop that flag so a later $lockedLoadRoutes + // cannot append the fixture path after this swap — otherwise + // controller("wheels") falls through to the last-path Controller.cfc + // stub (no mixins). Test-asset BrowserTest* controllers + tests/routes.cfm + // keep /_browser working. + application.wo.set(loadBrowserTestFixtures = false) + // Drop class caches from the isolated app's onApplicationStart + // (and from any prior run). Same reason as the model-cache clear + // below: those instances were baked against the live paths. + // StructClear — do not call application.wo.$clearControllerInitializationCache() + // here: this closure is Adobe 2025 invariant 16b (zero-arg call + // through the application scope). + if (StructKeyExists(application.wheels, "controllers")) { + StructClear(application.wheels.controllers) + } + if (StructKeyExists(application.wheels, "existingObjectFiles")) { + StructClear(application.wheels.existingObjectFiles) + } + if (StructKeyExists(application.wheels, "nonExistingObjectFiles")) { + StructClear(application.wheels.nonExistingObjectFiles) + } /* set migration level for tests*/ application.wheels.migrationLevel = 2; @@ -166,143 +188,182 @@ bundlesDiscovered = local.bundlesDiscovered ) - variables.$_setTestboxEnv() - if (!structKeyExists(url, "format") || url.format eq "html") { - result = testBox.run( - reporter = "wheels.wheelstest.system.reports.JSONReporter" - ); - DeJsonResult = DeserializeJSON(result); - - if (DeJsonResult.totalFail > 0 || DeJsonResult.totalError > 0) { - application.wo.$header(statuscode=417); - } else { - application.wo.$header(statuscode=200); - } - } - else if(url.format eq "json"){ - result = testBox.run( - reporter = "wheels.wheelstest.system.reports.JSONReporter" - ); - // `$header()` / `$content()` short-circuit when the servlet response is - // already committed (Adobe CF 2023/2025 commits mid-`testBox.run()` once - // any test output flushes the buffer). The status-code header is the - // signal the CI parser keys on, so best-effort is the right contract — - // a committed response keeps whatever statuscode the engine already - // wrote, and the JSON body still appends below. - application.wo.$content(type="application/json"); - application.wo.$header(name="Access-Control-Allow-Origin", value="*"); - DeJsonResult = DeserializeJSON(result); - if (DeJsonResult.totalFail > 0 || DeJsonResult.totalError > 0) { - if(!structKeyExists(url, "cli") || !url.cli){ - application.wo.$header(statuscode=417); + // ── Concurrency guard (issue #3025) + isolated app (issue #3374) ── + // The swap→run→restore window below mutates application.wheels + // ($_setTestboxEnv backs it up in application.$$$wheels and swaps + // in test config; the finally block swaps it back). When + // Application.cfc includes events/testcontext.cfm, test-runner + // requests bind `_wheelsTest` — a separate CFML application + // scope — so this swap never touches the live app's application.wheels. + // Apps that have not applied that snippet still swap the live scope; + // the exclusive named lock below remains the fallback (precedent: + // migrator/TenantMigrator.cfc::$runForTenant). + // + // Two overlapping test requests used to clobber each other's backup, + // which could restore TEST config as the live config until the next + // reload=true. Serialize the whole window under an exclusive named lock. + // + // Re-entrancy: ParallelRunner partitions re-enter this template via + // fresh top-level HTTP GETs while the parent request holds the swap and + // the lock. Those sub-requests detect the already-applied swap + // (application.$$$wheels exists) and skip BOTH the swap and the shared + // lock — contending on the parent's lock would deadlock parallel mode. + // A unique per-request suffix turns their lock into a no-op. + local.runnerOwnsSwap = !StructKeyExists(application, "$$$wheels"); + local.runnerLockSuffix = local.runnerOwnsSwap ? "" : "_sub_" & CreateUUID(); + // Timeout must exceed the worst-case full-suite duration on the slowest + // engine; matches the requestTimeout at the top of this template. + lock name="wheelsTestRunner_#application.applicationName##local.runnerLockSuffix#" type="exclusive" timeout="1800" throwontimeout="true" { + try { + if (local.runnerOwnsSwap) { + variables.$_setTestboxEnv(); } - } else { - application.wo.$header(statuscode=200); - } - // Check if 'only' parameter is provided in the URL - if (structKeyExists(url, "only") && url.only eq "failure,error") { - allBundles = DeJsonResult.bundleStats; - if(DeJsonResult.totalFail > 0 || DeJsonResult.totalError > 0){ - - // Filter test results - filteredBundles = []; - - for (bundle in DeJsonResult.bundleStats) { - if (bundle.totalError > 0 || bundle.totalFail > 0) { - filteredSuites = []; - - for (suite in bundle.suiteStats) { - if (suite.totalError > 0 || suite.totalFail > 0) { - filteredSpecs = []; - - for (spec in suite.specStats) { - if (spec.status eq "Error" || spec.status eq "Failed") { - arrayAppend(filteredSpecs, spec); + if (!structKeyExists(url, "format") || url.format eq "html") { + result = testBox.run( + reporter = "wheels.wheelstest.system.reports.JSONReporter" + ); + DeJsonResult = DeserializeJSON(result); + + if (DeJsonResult.totalFail > 0 || DeJsonResult.totalError > 0) { + application.wo.$header(statuscode=417); + } else { + application.wo.$header(statuscode=200); + } + } + else if(url.format eq "json"){ + result = testBox.run( + reporter = "wheels.wheelstest.system.reports.JSONReporter" + ); + // `$header()` / `$content()` short-circuit when the servlet response is + // already committed (Adobe CF 2023/2025 commits mid-`testBox.run()` once + // any test output flushes the buffer). The status-code header is the + // signal the CI parser keys on, so best-effort is the right contract — + // a committed response keeps whatever statuscode the engine already + // wrote, and the JSON body still appends below. + application.wo.$content(type="application/json"); + application.wo.$header(name="Access-Control-Allow-Origin", value="*"); + DeJsonResult = DeserializeJSON(result); + if (DeJsonResult.totalFail > 0 || DeJsonResult.totalError > 0) { + if(!structKeyExists(url, "cli") || !url.cli){ + application.wo.$header(statuscode=417); + } + } else { + application.wo.$header(statuscode=200); + } + // Check if 'only' parameter is provided in the URL + if (structKeyExists(url, "only") && url.only eq "failure,error") { + allBundles = DeJsonResult.bundleStats; + if(DeJsonResult.totalFail > 0 || DeJsonResult.totalError > 0){ + + // Filter test results + filteredBundles = []; + + for (bundle in DeJsonResult.bundleStats) { + if (bundle.totalError > 0 || bundle.totalFail > 0) { + filteredSuites = []; + + for (suite in bundle.suiteStats) { + if (suite.totalError > 0 || suite.totalFail > 0) { + filteredSpecs = []; + + for (spec in suite.specStats) { + if (spec.status eq "Error" || spec.status eq "Failed") { + arrayAppend(filteredSpecs, spec); + } + } + + if (arrayLen(filteredSpecs) > 0) { + suite.specStats = filteredSpecs; + arrayAppend(filteredSuites, suite); + } } } - if (arrayLen(filteredSpecs) > 0) { - suite.specStats = filteredSpecs; - arrayAppend(filteredSuites, suite); + if (arrayLen(filteredSuites) > 0) { + bundle.suiteStats = filteredSuites; + arrayAppend(filteredBundles, bundle); } } } - if (arrayLen(filteredSuites) > 0) { - bundle.suiteStats = filteredSuites; - arrayAppend(filteredBundles, bundle); - } - } - } - - DeJsonResult.bundleStats = filteredBundles; - // Update the result with filtered data + DeJsonResult.bundleStats = filteredBundles; + // Update the result with filtered data - // Build lookup of filtered bundles by name for safe access - filteredBundleMap = {}; - for (fb in filteredBundles) { - filteredBundleMap[fb.name] = fb; - } + // Build lookup of filtered bundles by name for safe access + filteredBundleMap = {}; + for (fb in filteredBundles) { + filteredBundleMap[fb.name] = fb; + } - for(bundle in allBundles){ - writeOutput("Bundle: #bundle.name##Chr(13)##Chr(10)#") - writeOutput("CFML Engine: #DeJsonResult.CFMLEngine# #DeJsonResult.CFMLEngineVersion##Chr(13)##Chr(10)#") - writeOutput("Duration: #bundle.totalDuration#ms#Chr(13)##Chr(10)#") - writeOutput("Labels: #ArrayToList(DeJsonResult.labels, ', ')##Chr(13)##Chr(10)#") - writeOutput("╔═══════════════════════════════════════════════════════════╗#Chr(13)##Chr(10)#║ Suites ║ Specs ║ Passed ║ Failed ║ Errored ║ Skipped ║#Chr(13)##Chr(10)#╠═══════════════════════════════════════════════════════════╣#Chr(13)##Chr(10)#║ #NumberFormat(bundle.totalSuites,'999')# ║ #NumberFormat(bundle.totalSpecs,'999')# ║ #NumberFormat(bundle.totalPass,'999')# ║ #NumberFormat(bundle.totalFail,'999')# ║ #NumberFormat(bundle.totalError,'999')# ║ #NumberFormat(bundle.totalSkipped,'999')# ║#Chr(13)##Chr(10)#╚═══════════════════════════════════════════════════════════╝#Chr(13)##Chr(10)##Chr(13)##Chr(10)#") - if(bundle.totalFail > 0 || bundle.totalError > 0){ - if (structKeyExists(filteredBundleMap, bundle.name)) { - for(suite in filteredBundleMap[bundle.name].suiteStats){ - writeOutput("Suite with Error or Failure: #suite.name##Chr(13)##Chr(10)##Chr(13)##Chr(10)#") - for(spec in suite.specStats){ - writeOutput(" Spec Name: #spec.name##Chr(13)##Chr(10)#") - writeOutput(" Error Message: #spec.failMessage##Chr(13)##Chr(10)#") - writeOutput(" Error Detail: #spec.failDetail##Chr(13)##Chr(10)##Chr(13)##Chr(10)##Chr(13)##Chr(10)#") + for(bundle in allBundles){ + writeOutput("Bundle: #bundle.name##Chr(13)##Chr(10)#") + writeOutput("CFML Engine: #DeJsonResult.CFMLEngine# #DeJsonResult.CFMLEngineVersion##Chr(13)##Chr(10)#") + writeOutput("Duration: #bundle.totalDuration#ms#Chr(13)##Chr(10)#") + writeOutput("Labels: #ArrayToList(DeJsonResult.labels, ', ')##Chr(13)##Chr(10)#") + writeOutput("╔═══════════════════════════════════════════════════════════╗#Chr(13)##Chr(10)#║ Suites ║ Specs ║ Passed ║ Failed ║ Errored ║ Skipped ║#Chr(13)##Chr(10)#╠═══════════════════════════════════════════════════════════╣#Chr(13)##Chr(10)#║ #NumberFormat(bundle.totalSuites,'999')# ║ #NumberFormat(bundle.totalSpecs,'999')# ║ #NumberFormat(bundle.totalPass,'999')# ║ #NumberFormat(bundle.totalFail,'999')# ║ #NumberFormat(bundle.totalError,'999')# ║ #NumberFormat(bundle.totalSkipped,'999')# ║#Chr(13)##Chr(10)#╚═══════════════════════════════════════════════════════════╝#Chr(13)##Chr(10)##Chr(13)##Chr(10)#") + if(bundle.totalFail > 0 || bundle.totalError > 0){ + if (structKeyExists(filteredBundleMap, bundle.name)) { + for(suite in filteredBundleMap[bundle.name].suiteStats){ + writeOutput("Suite with Error or Failure: #suite.name##Chr(13)##Chr(10)##Chr(13)##Chr(10)#") + for(spec in suite.specStats){ + writeOutput(" Spec Name: #spec.name##Chr(13)##Chr(10)#") + writeOutput(" Error Message: #spec.failMessage##Chr(13)##Chr(10)#") + writeOutput(" Error Detail: #spec.failDetail##Chr(13)##Chr(10)##Chr(13)##Chr(10)##Chr(13)##Chr(10)#") + } + } } } + writeOutput("#Chr(13)##Chr(10)##Chr(13)##Chr(10)##Chr(13)##Chr(10)#") } - } - writeOutput("#Chr(13)##Chr(10)##Chr(13)##Chr(10)##Chr(13)##Chr(10)#") - } - }else{ - for(bundle in DeJsonResult.bundleStats){ - writeOutput("Bundle: #bundle.name##Chr(13)##Chr(10)#") - writeOutput("CFML Engine: #DeJsonResult.CFMLEngine# #DeJsonResult.CFMLEngineVersion##Chr(13)##Chr(10)#") - writeOutput("Duration: #bundle.totalDuration#ms#Chr(13)##Chr(10)#") - writeOutput("Labels: #ArrayToList(DeJsonResult.labels, ', ')##Chr(13)##Chr(10)#") - writeOutput("╔═══════════════════════════════════════════════════════════╗#Chr(13)##Chr(10)#║ Suites ║ Specs ║ Passed ║ Failed ║ Errored ║ Skipped ║#Chr(13)##Chr(10)#╠═══════════════════════════════════════════════════════════╣#Chr(13)##Chr(10)#║ #NumberFormat(bundle.totalSuites,'999')# ║ #NumberFormat(bundle.totalSpecs,'999')# ║ #NumberFormat(bundle.totalPass,'999')# ║ #NumberFormat(bundle.totalFail,'999')# ║ #NumberFormat(bundle.totalError,'999')# ║ #NumberFormat(bundle.totalSkipped,'999')# ║#Chr(13)##Chr(10)#╚═══════════════════════════════════════════════════════════╝#Chr(13)##Chr(10)##Chr(13)##Chr(10)##Chr(13)##Chr(10)#") + }else{ + for(bundle in DeJsonResult.bundleStats){ + writeOutput("Bundle: #bundle.name##Chr(13)##Chr(10)#") + writeOutput("CFML Engine: #DeJsonResult.CFMLEngine# #DeJsonResult.CFMLEngineVersion##Chr(13)##Chr(10)#") + writeOutput("Duration: #bundle.totalDuration#ms#Chr(13)##Chr(10)#") + writeOutput("Labels: #ArrayToList(DeJsonResult.labels, ', ')##Chr(13)##Chr(10)#") + writeOutput("╔═══════════════════════════════════════════════════════════╗#Chr(13)##Chr(10)#║ Suites ║ Specs ║ Passed ║ Failed ║ Errored ║ Skipped ║#Chr(13)##Chr(10)#╠═══════════════════════════════════════════════════════════╣#Chr(13)##Chr(10)#║ #NumberFormat(bundle.totalSuites,'999')# ║ #NumberFormat(bundle.totalSpecs,'999')# ║ #NumberFormat(bundle.totalPass,'999')# ║ #NumberFormat(bundle.totalFail,'999')# ║ #NumberFormat(bundle.totalError,'999')# ║ #NumberFormat(bundle.totalSkipped,'999')# ║#Chr(13)##Chr(10)#╚═══════════════════════════════════════════════════════════╝#Chr(13)##Chr(10)##Chr(13)##Chr(10)##Chr(13)##Chr(10)#") + } + } + }else{ + // Thread the resolved-scope facts (and any warnings) into the JSON + // payload so a rejected directory or a 0-bundle discovery is + // detectable instead of masquerading as a green run (issue #3083). + writeOutput(local.scopeResolver.injectScopeMetadata( + resultJson = result, + scope = local.testScope, + bundlesDiscovered = local.bundlesDiscovered, + warnings = local.scopeWarnings + )) } } - }else{ - // Thread the resolved-scope facts (and any warnings) into the JSON - // payload so a rejected directory or a 0-bundle discovery is - // detectable instead of masquerading as a green run (issue #3083). - writeOutput(local.scopeResolver.injectScopeMetadata( - resultJson = result, - scope = local.testScope, - bundlesDiscovered = local.bundlesDiscovered, - warnings = local.scopeWarnings - )) + else if (url.format eq "txt") { + result = testBox.run( + reporter = "wheels.wheelstest.system.reports.TextReporter" + ) + application.wo.$content(type="text/plain"); + writeOutput(result) + } + else if(url.format eq "junit"){ + result = testBox.run( + reporter = "wheels.wheelstest.system.reports.ANTJUnitReporter" + ) + application.wo.$content(type="text/xml"); + writeOutput(result) + } + } finally { + // Reset the original environment. Only the request that created + // the backup restores it — sub-requests never touch the live + // config — and the restore now also runs when the suite errors + // out (previously an exception left test config live until the + // next reload). No loops in this finally block (Lucee 7 + // miscompiles local-scoped loops in finally — invariant 12). + if (local.runnerOwnsSwap && StructKeyExists(application, "$$$wheels")) { + application.wheels = application.$$$wheels; + structDelete(application, "$$$wheels"); + } } } - else if (url.format eq "txt") { - result = testBox.run( - reporter = "wheels.wheelstest.system.reports.TextReporter" - ) - application.wo.$content(type="text/plain"); - writeOutput(result) - } - else if(url.format eq "junit"){ - result = testBox.run( - reporter = "wheels.wheelstest.system.reports.ANTJUnitReporter" - ) - application.wo.$content(type="text/xml"); - writeOutput(result) - } - // reset the original environment - application.wheels = application.$$$wheels - structDelete(application, "$$$wheels") if(!structKeyExists(url, "format") || url.format eq "html"){ // Use our html template type = "Core"; diff --git a/vendor/wheels/tests/specs/auth/PasswordHasherSpec.cfc b/vendor/wheels/tests/specs/auth/PasswordHasherSpec.cfc new file mode 100644 index 0000000000..c2e18091b2 --- /dev/null +++ b/vendor/wheels/tests/specs/auth/PasswordHasherSpec.cfc @@ -0,0 +1,181 @@ +component extends="wheels.WheelsTest" { + + function run() { + + describe("PasswordHasher", function() { + + beforeEach(function() { + // Low iteration count keeps the suite fast; the algorithm is the + // same regardless of count. Default-count behavior is asserted + // in its own spec below. + hasher = new wheels.auth.PasswordHasher(iterations = 1000); + }); + + describe("init() validation", function() { + + it("throws InvalidConfiguration for zero iterations", function() { + expect(function() { + var svc = new wheels.auth.PasswordHasher(iterations = 0); + }).toThrow("Wheels.PasswordHasher.InvalidConfiguration"); + }); + + it("throws InvalidConfiguration for negative iterations", function() { + expect(function() { + var svc = new wheels.auth.PasswordHasher(iterations = -1); + }).toThrow("Wheels.PasswordHasher.InvalidConfiguration"); + }); + + it("throws InvalidConfiguration for non-integer iterations", function() { + expect(function() { + var svc = new wheels.auth.PasswordHasher(iterations = 1000.5); + }).toThrow("Wheels.PasswordHasher.InvalidConfiguration"); + }); + + it("defaults to 600000 iterations (OWASP 2023+)", function() { + var svc = new wheels.auth.PasswordHasher(); + var h = svc.hash("secret"); + expect(ListGetAt(h, 2, "$")).toBe("i=600000"); + }); + + }); + + describe("hash()", function() { + + it("produces the self-describing modular-crypt format", function() { + var h = hasher.hash("correct horse battery staple"); + // $pbkdf2-sha256$i=$$ + expect(Left(h, 1)).toBe("$"); + var parts = ListToArray(h, "$"); + expect(ArrayLen(parts)).toBe(4); + expect(parts[1]).toBe("pbkdf2-sha256"); + expect(parts[2]).toBe("i=1000"); + // Salt decodes to 16 random bytes, derived key to 32 bytes (256 bits) + expect(Len(BinaryDecode(parts[3], "base64"))).toBe(16); + expect(Len(BinaryDecode(parts[4], "base64"))).toBe(32); + }); + + it("produces different hashes for the same password (random salt)", function() { + var first = hasher.hash("same-password"); + var second = hasher.hash("same-password"); + expect(Compare(first, second)).notToBe(0); + // And both still verify + expect(hasher.verify("same-password", first)).toBeTrue(); + expect(hasher.verify("same-password", second)).toBeTrue(); + }); + + it("hashes an empty password (minimum-length policy lives in app validations)", function() { + var h = hasher.hash(""); + expect(hasher.verify("", h)).toBeTrue(); + expect(hasher.verify("not-empty", h)).toBeFalse(); + }); + + }); + + describe("verify()", function() { + + it("returns true for the correct password", function() { + var h = hasher.hash("s3cret!"); + expect(hasher.verify("s3cret!", h)).toBeTrue(); + }); + + it("returns false for the wrong password", function() { + var h = hasher.hash("s3cret!"); + expect(hasher.verify("wrong-password", h)).toBeFalse(); + }); + + it("is case-sensitive on the password", function() { + var h = hasher.hash("Secret"); + expect(hasher.verify("secret", h)).toBeFalse(); + }); + + it("round-trips unicode passwords via UTF-8 bytes", function() { + var unicodePassword = "pässwörd-契約-κωδικός"; + var h = hasher.hash(unicodePassword); + expect(hasher.verify(unicodePassword, h)).toBeTrue(); + expect(hasher.verify("passwoerd", h)).toBeFalse(); + }); + + it("verifies hashes produced under a different iteration count (stored count wins)", function() { + var older = new wheels.auth.PasswordHasher(iterations = 500); + var h = older.hash("migrate-me"); + // A hasher configured with more iterations still verifies the stored hash + expect(hasher.verify("migrate-me", h)).toBeTrue(); + }); + + it("returns false (never throws) for an empty hash", function() { + expect(hasher.verify("anything", "")).toBeFalse(); + }); + + it("returns false (never throws) for a non-hash string", function() { + expect(hasher.verify("anything", "not-a-hash-at-all")).toBeFalse(); + }); + + it("returns false (never throws) for a truncated hash", function() { + var h = hasher.hash("s3cret!"); + // Drop the derived-key segment entirely + var truncated = "$" & ListGetAt(h, 1, "$") & "$" & ListGetAt(h, 2, "$") & "$" & ListGetAt(h, 3, "$"); + expect(hasher.verify("s3cret!", truncated)).toBeFalse(); + }); + + it("returns false (never throws) for an unknown algorithm tag", function() { + var h = hasher.hash("s3cret!"); + var foreign = Replace(h, "pbkdf2-sha256", "argon2id"); + expect(hasher.verify("s3cret!", foreign)).toBeFalse(); + }); + + it("returns false (never throws) for invalid base64 in the hash", function() { + expect(hasher.verify("anything", "$pbkdf2-sha256$i=1000$!!!not-base64!!!$%%%also-bad%%%")).toBeFalse(); + }); + + it("returns false (never throws) for a zero-iterations hash", function() { + var h = hasher.hash("s3cret!"); + var doctored = Replace(h, "i=1000", "i=0"); + expect(hasher.verify("s3cret!", doctored)).toBeFalse(); + }); + + it("returns false when the format lacks the leading dollar sign", function() { + var h = hasher.hash("s3cret!"); + var noPrefix = Right(h, Len(h) - 1); + expect(hasher.verify("s3cret!", noPrefix)).toBeFalse(); + }); + + }); + + describe("needsRehash()", function() { + + it("returns false for a hash produced at the configured iteration count", function() { + var h = hasher.hash("s3cret!"); + expect(hasher.needsRehash(h)).toBeFalse(); + }); + + it("returns true when the stored iteration count is below the configured one", function() { + var older = new wheels.auth.PasswordHasher(iterations = 500); + var h = older.hash("migrate-me"); + expect(hasher.needsRehash(h)).toBeTrue(); + }); + + it("returns false when the stored iteration count exceeds the configured one", function() { + var stronger = new wheels.auth.PasswordHasher(iterations = 2000); + var h = stronger.hash("already-strong"); + expect(hasher.needsRehash(h)).toBeFalse(); + }); + + it("returns true for an unknown algorithm tag", function() { + var h = hasher.hash("s3cret!"); + var foreign = Replace(h, "pbkdf2-sha256", "argon2id"); + expect(hasher.needsRehash(foreign)).toBeTrue(); + }); + + it("returns true for a malformed hash", function() { + expect(hasher.needsRehash("")).toBeTrue(); + expect(hasher.needsRehash("not-a-hash")).toBeTrue(); + expect(hasher.needsRehash("$pbkdf2-sha256$i=1000$only-three-parts")).toBeTrue(); + }); + + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/channel/DatabaseAdapterSpec.cfc b/vendor/wheels/tests/specs/channel/DatabaseAdapterSpec.cfc index 42a77abe76..02c77c9cff 100644 --- a/vendor/wheels/tests/specs/channel/DatabaseAdapterSpec.cfc +++ b/vendor/wheels/tests/specs/channel/DatabaseAdapterSpec.cfc @@ -222,6 +222,160 @@ component extends="wheels.WheelsTest" { expect(remaining.recordCount).toBe(3); }); + it("runs the bounded-pass statements against the live database", function() { + // The $applyRowBound tests below only compare strings — nothing sends + // the rewritten SQL to a real database, and nothing exercises the + // list-parameter DELETE the bounded pass pairs it with. cleanup() + // catches every error and returns 0, so an engine/database pair that + // rejects either statement presents only as a wrong row count with no + // message: "Expected [2] but received [0]" on boxlang + postgres and + // cockroachdb, with the reason only in the wheels_channels log + // (#3302). Running both statements here without the catch makes the + // database's own error the thing the suite reports. + adapter.cleanup(); + + queryExecute( + "INSERT INTO wheels_events (id, channel, event, data, createdAt) + VALUES (:id, :channel, :event, :data, :createdAt)", + { + id: {value: "livebound-evt-1", cfsqltype: "cf_sql_varchar"}, + channel: {value: "test.livebound", cfsqltype: "cf_sql_varchar"}, + event: {value: "old", cfsqltype: "cf_sql_varchar"}, + data: {value: "expired", cfsqltype: "cf_sql_longvarchar"}, + createdAt: {value: DateAdd("h", -2, Now()), cfsqltype: "cf_sql_timestamp"} + }, + {datasource: application.wheels.dataSourceName} + ); + + var cutoff = DateAdd("n", -60, Now()); + var dialect = adapter.$detectDatabaseType(); + var candidateSql = adapter.$applyRowBound( + sqlText = "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC", + dbType = dialect, + maxRows = 1 + ); + + // Mirror cleanup()'s option handling exactly: the driver-level + // maxrows bound is used only when the dialect rewrite applied none. + // Setting it unconditionally is what threw on boxlang + pgjdbc. + var options = {datasource: application.wheels.dataSourceName}; + if (candidateSql == "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC") { + options.maxrows = 1; + } + var candidates = queryExecute( + candidateSql, + {cutoff: {value: cutoff, cfsqltype: "cf_sql_timestamp"}}, + options + ); + expect(candidates.recordCount).toBe( + 1, + "The dialect-bounded SELECT returned no rows on #dialect#. SQL was: #candidateSql#" + ); + + // Assert against the id the bounded SELECT actually returned rather + // than against the row inserted above: ORDER BY createdAt ASC takes + // the oldest expired row in the table, which need not be ours if a + // previous bundle left one behind. + var targetId = candidates.id[1]; + + queryExecute( + "DELETE FROM wheels_events WHERE createdAt < :cutoff AND id IN (:ids)", + { + cutoff: {value: cutoff, cfsqltype: "cf_sql_timestamp"}, + ids: {value: ValueList(candidates.id), cfsqltype: "cf_sql_varchar", list: true} + }, + {datasource: application.wheels.dataSourceName} + ); + + var survivor = queryExecute( + "SELECT id FROM wheels_events WHERE id = :id", + {id: {value: targetId, cfsqltype: "cf_sql_varchar"}}, + {datasource: application.wheels.dataSourceName} + ); + expect(survivor.recordCount).toBe( + 0, + "The list-parameter DELETE ran without error on #dialect# but did not " + & "remove the row the bounded SELECT had just identified." + ); + + queryExecute( + "DELETE FROM wheels_events WHERE channel = :channel", + {channel: {value: "test.livebound", cfsqltype: "cf_sql_varchar"}}, + {datasource: application.wheels.dataSourceName} + ); + }); + + it("$applyRowBound rewrites the SELECT with TOP for sqlserver", function() { + var bounded = adapter.$applyRowBound( + sqlText = "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC", + dbType = "sqlserver", + maxRows = 25 + ); + expect(bounded).toBe( + "SELECT TOP 25 id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC" + ); + }); + + it("$applyRowBound appends FETCH FIRST for oracle", function() { + var bounded = adapter.$applyRowBound( + sqlText = "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC", + dbType = "oracle", + maxRows = 25 + ); + expect(bounded).toBe( + "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC FETCH FIRST 25 ROWS ONLY" + ); + }); + + it("$applyRowBound appends LIMIT for the explicit LIMIT dialects", function() { + var dialects = ["mysql", "postgresql", "sqlite", "h2"]; + for (var dialect in dialects) { + var bounded = adapter.$applyRowBound( + sqlText = "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC", + dbType = dialect, + maxRows = 25 + ); + expect(bounded).toBe( + "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC LIMIT 25" + ); + } + }); + + it("$applyRowBound leaves unknown dialects unchanged so driver maxrows stays the bound", function() { + // "default" is what $detectDatabaseType() returns when cfdbinfo fails — + // appending LIMIT there would be a syntax error on SQL Server/Oracle, + // silently breaking cleanup() on the engines that need dialect handling. + var dialects = ["default", "informix"]; + for (var dialect in dialects) { + var unchanged = adapter.$applyRowBound( + sqlText = "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC", + dbType = dialect, + maxRows = 25 + ); + expect(unchanged).toBe( + "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC" + ); + } + }); + + it("$applyRowBound hardens the bound to an integer", function() { + var bounded = adapter.$applyRowBound( + sqlText = "SELECT id FROM wheels_events", + dbType = "mysql", + maxRows = 7.9 + ); + expect(bounded).toBe("SELECT id FROM wheels_events LIMIT 7"); + }); + + it("$applyRowBound leaves the statement unchanged for a non-positive bound", function() { + var unbounded = adapter.$applyRowBound( + sqlText = "SELECT id FROM wheels_events", + dbType = "mysql", + maxRows = 0 + ); + expect(unbounded).toBe("SELECT id FROM wheels_events"); + }); + it("auto-creates wheels_events table on first use", function() { // The table should already exist from previous tests, // but verify we can query it diff --git a/vendor/wheels/tests/specs/cli/CliBridgeSpec.cfc b/vendor/wheels/tests/specs/cli/CliBridgeSpec.cfc new file mode 100644 index 0000000000..18da65b5c9 --- /dev/null +++ b/vendor/wheels/tests/specs/cli/CliBridgeSpec.cfc @@ -0,0 +1,100 @@ +/** + * Unit specs for the CliBridge service (issue ##2959, P2). + * + * The dev-UI dispatcher `vendor/wheels/public/views/cli.cfm` was a ~935-line + * template with a 44-case switch whose handlers could not be unit-tested + * because the template only runs under a full HTTP request context. The + * handlers were extracted into `wheels.public.CliBridge` — a plain, + * stateless component with one method per command and an explicit + * command->method allowlist. cli.cfm is now a thin dispatcher that builds a + * context, checks `handles()`, and calls `dispatch()`. + * + * Because CliBridge is a plain component, the dispatch contract and the + * pure (no-DB) handler branches ARE unit-testable here — the regression net + * the god-template never had. DB- and worker-backed handlers are + * behaviour-preserving moves verified by the cross-engine matrix and live + * endpoint testing. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("CliBridge dispatch contract (issue ##2959)", () => { + + beforeEach(() => { + bridge = new wheels.public.CliBridge(); + }); + + it("handles() returns true for every declared command", () => { + var declared = "createMigration,migrateTo,migrateToLatest,migrateUp,migrateDown," + & "renameSystemTables,diff,redoMigration,info,doctor,forgetVersion,pretendVersion," + & "dbStatus,dbVersion,dbRollback,dbSchema,introspect,dbSeed,routes,dbCreate,dbDrop," + & "dbReset,dbSetup,dbDump,dbRestore,dbShell,jobsProcessNext,jobsStatus,jobsRetry," + & "jobsPurge,jobsMonitor"; + for (var cmd in ListToArray(declared)) { + expect(bridge.handles(cmd)).toBeTrue("CliBridge should handle '" & cmd & "'"); + } + }); + + it("handles() returns false for unknown or unsafe command names", () => { + expect(bridge.handles("")).toBeFalse(); + expect(bridge.handles("notACommand")).toBeFalse(); + // Must NOT expose arbitrary component methods as commands. + expect(bridge.handles("init")).toBeFalse(); + expect(bridge.handles("dispatch")).toBeFalse(); + expect(bridge.handles("handles")).toBeFalse(); + }); + + it("dispatch() throws for a command not on the allowlist (defensive guard)", () => { + var call = () => { + bridge.dispatch(command = "notACommand", context = {}, params = {}); + }; + expect(call).toThrow("Wheels.UnknownCliCommand"); + }); + + }); + + describe("CliBridge pure handler branches (issue ##2959)", () => { + + beforeEach(() => { + bridge = new wheels.public.CliBridge(); + }); + + it("dbVersion reports the current version from the context", () => { + var rv = bridge.dispatch( + command = "dbVersion", + context = {currentVersion = "20260101000000"}, + params = {} + ); + expect(rv.success).toBeTrue(); + expect(rv.version).toBe("20260101000000"); + expect(rv.message).toInclude("20260101000000"); + }); + + it("introspect returns a missing-parameter error when no model is given", () => { + var rv = bridge.dispatch(command = "introspect", context = {}, params = {}); + expect(rv.success).toBeFalse(); + expect(rv.message).toInclude("Missing required parameter: model"); + }); + + it("forgetVersion returns a missing-argument error when no version is given", () => { + var rv = bridge.dispatch(command = "forgetVersion", context = {}, params = {}); + expect(rv.success).toBeFalse(); + expect(rv.message).toInclude("Missing required argument: version"); + }); + + it("migrateToLatest delegates to the migrator and returns its message", () => { + var fakeMigrator = {migrateToLatest = () => "Migrated to 20260101000000."}; + var rv = bridge.dispatch( + command = "migrateToLatest", + context = {migrator = fakeMigrator}, + params = {} + ); + expect(rv.message).toBe("Migrated to 20260101000000."); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/cli/ConfigRoutesStaleDocUrlSpec.cfc b/vendor/wheels/tests/specs/cli/ConfigRoutesStaleDocUrlSpec.cfc index 9c10d570fc..b226dcc05d 100644 --- a/vendor/wheels/tests/specs/cli/ConfigRoutesStaleDocUrlSpec.cfc +++ b/vendor/wheels/tests/specs/cli/ConfigRoutesStaleDocUrlSpec.cfc @@ -1,21 +1,22 @@ /** - * Regression: scaffolded config/routes.cfm shipped a `// See https://...` doc - * URL pointing at `https://guides.wheels.dev/docs/routing` — a path that - * doesn't exist on the current docs site. The lucli scaffolder's active - * `config/routes.cfm` template was already updated to the canonical - * `/v4-0-0-snapshot/handling-requests-with-controllers/routing` URL, but two - * sibling templates that produce the same comment for older code paths still - * had the broken link: + * Regression: scaffolded files shipped `See https://...` doc URLs pointing at + * guide paths that no longer exist on the docs site. * - * - cli/src/templates/ConfigRoutes.txt - * - cli/lucli/templates/app/app/snippets/ConfigRoutes.txt + * Round 1 (issue ##2635): config/routes.cfm templates pointed at the retired + * `guides.wheels.dev/docs/routing` path. * - * Both are user-facing on freshly scaffolded apps. Issue ##2635. + * Round 2 (2026-07): the pre-GA `v4-0-0-snapshot` slug was retired when the + * 4.0 docs consolidated onto `v4-0-0`, which killed every templated URL still + * carrying it — including the `working-with-wheels/*` paths that only ever + * existed in the v3 tree. The scaffolded settings.cfm/routes.cfm/environment.cfm, + * three template READMEs, two runtime CLI messages (Module.cfc, Doctor.cfc), + * and the demo app's config all linked 404s. All were repointed at live + * `guides.wheels.dev/v4-0-0/...` pages. * - * Also guards against any reintroduction of `cfwheels.org`, `cfwheels.com`, - * or `docs.cfwheels.org` URLs in these template files, since those domains - * were retired at the 3.0 rebrand and only `wheels.dev` / `guides.wheels.dev` - * remain canonical. + * This spec pins the canonical routing URL in the routes templates AND scans + * the scaffold template tree plus the known runtime-message files for any + * reintroduction of retired URL shapes: `v4-0-0-snapshot`, `wheels.dev/3.1.0`, + * and the rebrand-retired cfwheels.org / cfwheels.com / docs.cfwheels.org hosts. */ component extends="wheels.WheelsTest" { @@ -31,7 +32,7 @@ component extends="wheels.WheelsTest" { "cli/lucli/templates/app/app/snippets/ConfigRoutes.txt", "cli/lucli/templates/app/config/routes.cfm" ]; - var canonical = "https://guides.wheels.dev/v4-0-0-snapshot/handling-requests-with-controllers/routing"; + var canonical = "https://guides.wheels.dev/v4-0-0/basics/routing/"; for (var rel in targets) { // Capture the loop variable so the closure body binds the @@ -45,26 +46,72 @@ component extends="wheels.WheelsTest" { expect(content contains canonical).toBeTrue( relPath & " should reference " & canonical - & " — the same URL used by cli/lucli/templates/app/config/routes.cfm." + & " — the live v4 routing guide." ); expect(content contains "guides.wheels.dev/docs/routing").toBeFalse( relPath & " still references the stale /docs/routing path on guides.wheels.dev." ); - - expect(content contains "docs.cfwheels.org").toBeFalse( - relPath & " still references the retired docs.cfwheels.org host." - ); - - expect(reFindNoCase("cfwheels\.(org|com)", content) > 0).toBeFalse( - relPath & " still references a retired cfwheels.org / cfwheels.com URL." - ); }); })(rel); } }); + describe("Retired guide URL shapes", () => { + + var repoRoot = expandPath("/wheels/../.."); + + // Files outside the template tree that print or ship guide URLs. + var extraFiles = [ + "cli/README.md", + "cli/lucli/Module.cfc", + "cli/lucli/services/Doctor.cfc", + "cli/src/templates/ConfigRoutes.txt", + "cli/src/commands/wheels/analyze/code.cfc", + "config/settings.cfm", + "config/environment.cfm", + "config/routes.cfm" + ]; + + it("no retired guide URLs under cli/lucli/templates/ or the known runtime-message files", () => { + var scanned = []; + var templateRoot = repoRoot & "/cli/lucli/templates"; + var templateFiles = directoryList(templateRoot, true, "path"); + for (var path in templateFiles) { + if (reFindNoCase("\.(cfm|cfc|txt|md|json)$", path)) { + arrayAppend(scanned, path); + } + } + for (var rel in extraFiles) { + arrayAppend(scanned, repoRoot & "/" & rel); + } + + var offenders = []; + for (var path in scanned) { + if (!fileExists(path)) { + continue; + } + var content = fileRead(path); + if ( + findNoCase("v4-0-0-snapshot", content) + || findNoCase("wheels.dev/3.1.0", content) + || findNoCase("docs.cfwheels.org", content) + || reFindNoCase("cfwheels\.(org|com)", content) + ) { + arrayAppend(offenders, path); + } + } + + expect(arrayLen(offenders) == 0).toBeTrue( + "Retired guide URL shape (v4-0-0-snapshot, wheels.dev/3.1.0, or a cfwheels.org-era host) found in: " + & arrayToList(offenders, "; ") + & ". Point these at live guides.wheels.dev/v4-0-0/ pages instead." + ); + }); + + }); + } } diff --git a/vendor/wheels/tests/specs/cli/OnApplicationEndScopeGuardSpec.cfc b/vendor/wheels/tests/specs/cli/OnApplicationEndScopeGuardSpec.cfc new file mode 100644 index 0000000000..3efbf5814b --- /dev/null +++ b/vendor/wheels/tests/specs/cli/OnApplicationEndScopeGuardSpec.cfc @@ -0,0 +1,209 @@ +/** + * Regression for issue ##3379 — "Element wo is undefined in a Java object of + * type class [Ljava.lang.String;". + * + * On Adobe ColdFusion 2023 the framework's onApplicationEnd() handler fires + * synchronously during applicationStop() teardown (e.g. a ?reload restart or + * an idle-timeout reclaim). Inside that teardown the LIVE `application` scope + * is no longer reliable — bare `application.wo` can resolve against a + * stale/torn-down scope and land on a Java String[] instead of the Wheels + * global, throwing "Element wo is undefined in a Java object of type class + * [Ljava.lang.String;". The whole site then errors until the CF service is + * restarted. + * + * The only dependable reference during shutdown is the passed-in + * arguments.applicationScope (already used for the $wheelsBrowserLauncher + * cleanup in the same handler). The fix routes the onapplicationend.cfm + * include through arguments.applicationScope.wo and guards it with + * StructKeyExists(arguments.applicationScope, "wo") so a partially reclaimed + * scope degrades to a no-op instead of a hard error. + * + * This is a structural guard: the failure only manifests on Adobe CF during + * real teardown, which cannot be reproduced inside a spec without killing the + * runner. So we assert the source shape across every shipped Application.cfc + * that declares onApplicationEnd — the CLI template (`wheels new`), the repo + * demo app, and the bundled example apps. A discovery check walks those + * trees so a newly added copy cannot slip the list. Mirrors + * OnErrorFallbackGuardSpec (issue ##2773). + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("Application.cfc onApplicationEnd scope hardening (issue ##3379)", () => { + + // expandPath("/wheels") resolves to vendor/wheels via the configured + // Lucee mapping; the repo root is two levels above. + var repoRoot = expandPath("/wheels/../.."); + var targets = [ + "cli/lucli/templates/app/public/Application.cfc", + "public/Application.cfc", + "examples/starter-app/public/Application.cfc", + "examples/tweet/public/Application.cfc" + ]; + + it("scans every shipped Application.cfc that declares onApplicationEnd", () => { + var discovered = $discoverShippedOnApplicationEndHandlers(repoRoot); + expect(ArrayLen(discovered) > 0).toBeTrue( + "Expected to discover at least one shipped Application.cfc " + & "that declares onApplicationEnd under cli/lucli/templates, " + & "public/, or examples/." + ); + for (var relPath in targets) { + expect(ArrayFindNoCase(discovered, relPath) > 0).toBeTrue( + "Required shipped handler " & relPath + & " was not discovered. Found: " & ArrayToList(discovered) + ); + } + for (var foundPath in discovered) { + expect(ArrayFindNoCase(targets, foundPath) > 0).toBeTrue( + "Shipped Application.cfc " & foundPath + & " declares onApplicationEnd but is not in the guard's " + & "targets list. Add it so a future revert cannot go " + & "uncaught (issue ##3379)." + ); + } + }); + + for (var rel in targets) { + // Capture the loop variable so the closure body binds the + // current value, not the final iteration's value. + (function(relPath) { + it("routes onApplicationEnd through arguments.applicationScope.wo in " & relPath, () => { + var absolute = repoRoot & "/" & relPath; + expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute); + + var raw = fileRead(absolute); + var content = $stripCfmlComments(raw); + + // Extract the onApplicationEnd function body so we don't + // pick up references from other handlers. + var fnMatch = reFindNoCase( + "(?s)public\s+void\s+function\s+onApplicationEnd\s*\([^\)]*\)\s*\{", + content, + 1, + true + ); + expect(fnMatch.len[1] > 0).toBeTrue( + relPath & " should declare a public void onApplicationEnd() function." + ); + + var bodyStart = fnMatch.pos[1] + fnMatch.len[1]; + var depth = 1; + var bodyEnd = bodyStart; + var iEnd = len(content); + for (var i = bodyStart; i <= iEnd; i++) { + var ch = mid(content, i, 1); + if (ch == "{") { + depth++; + } else if (ch == "}") { + depth--; + if (depth == 0) { + bodyEnd = i - 1; + break; + } + } + } + var fnBody = mid(content, bodyStart, bodyEnd - bodyStart + 1); + + // 1. The handler must NOT dereference the live application + // scope — bare `application.wo` is exactly what breaks on + // Adobe CF during teardown. + expect( + reFindNoCase("application\.wo\.", fnBody) == 0 + ).toBeTrue( + relPath & " onApplicationEnd() must not dereference the live " + & "application scope (application.wo.*). During Adobe CF 2023 " + & "teardown that resolves to a stale Java String[] and throws " + & "'Element wo is undefined...' (issue ##3379). Route the call " + & "through arguments.applicationScope.wo instead." + ); + + // 2. The include must be routed through the passed-in + // application scope, the only reliable reference at + // shutdown. + expect( + reFindNoCase("arguments\.applicationScope\.wo\.", fnBody) > 0 + ).toBeTrue( + relPath & " onApplicationEnd() must invoke the Wheels global via " + & "arguments.applicationScope.wo (mirroring the $wheelsBrowserLauncher " + & "cleanup) so it survives teardown on Adobe CF (issue ##3379)." + ); + + // 3. The dereference must be guarded so a partially reclaimed + // scope degrades to a no-op instead of a hard error. + var guardPos = reFindNoCase( + "StructKeyExists\s*\(\s*arguments\.applicationScope\s*,\s*[""']wo[""']\s*\)", + fnBody + ); + var derefPos = reFindNoCase("arguments\.applicationScope\.wo\.", fnBody); + expect(guardPos > 0 && guardPos < derefPos).toBeTrue( + relPath & " onApplicationEnd() must guard " + & "arguments.applicationScope.wo with " + & "StructKeyExists(arguments.applicationScope, ""wo"") before " + & "dereferencing it, so a torn-down scope short-circuits cleanly " + & "(issue ##3379)." + ); + }); + })(rel); + } + + }); + + } + + /** + * Walk the trees that ship an Application.cfc to users (CLI `wheels new` + * template, repo demo app, bundled examples) and return repo-relative + * paths of every file that still declares onApplicationEnd after comment + * stripping. Test-only Application.cfc copies under vendor/wheels/tests, + * rocketunit_tests, and cli/lucli/tests are out of scope — they are not + * shipped to apps. + */ + private array function $discoverShippedOnApplicationEndHandlers(required string repoRoot) { + var shippedRoots = ["cli/lucli/templates", "public", "examples"]; + var found = []; + var rootNormalized = Replace(arguments.repoRoot, "\", "/", "all"); + if (Right(rootNormalized, 1) == "/" && Len(rootNormalized) > 1) { + rootNormalized = Left(rootNormalized, Len(rootNormalized) - 1); + } + + for (var relRoot in shippedRoots) { + var absoluteRoot = rootNormalized & "/" & relRoot; + if (!DirectoryExists(absoluteRoot)) { + continue; + } + var files = DirectoryList(absoluteRoot, true, "path", "*.cfc"); + for (var filePath in files) { + if (ListLast(filePath, "/\") != "Application.cfc") { + continue; + } + var content = $stripCfmlComments(FileRead(filePath)); + if (reFindNoCase("function\s+onApplicationEnd\s*\(", content) == 0) { + continue; + } + var normalized = Replace(filePath, "\", "/", "all"); + var rel = Mid(normalized, Len(rootNormalized) + 2, Len(normalized)); + ArrayAppend(found, rel); + } + } + + ArraySort(found, "textnocase"); + return found; + } + + /** + * Strip CFML tag, block, and line comments before scanning. Mirrors + * the helpers under cli/lucli/services (Analysis.cfc, Doctor.cfc) so a + * commented-out access pattern doesn't pollute the structural check + * (CLAUDE.md anti-pattern ##14). + */ + private string function $stripCfmlComments(required string source) { + var stripped = arguments.source; + stripped = reReplace(stripped, "", "", "all"); + stripped = reReplace(stripped, "/\*[\s\S]*?\*/", "", "all"); + stripped = reReplace(stripped, "(?m)//[^\n]*", "", "all"); + return stripped; + } + +} diff --git a/vendor/wheels/tests/specs/cli/PackagesCommandHelpSpec.cfc b/vendor/wheels/tests/specs/cli/PackagesCommandHelpSpec.cfc index 5ba2ccbb6b..05cdf17c01 100644 --- a/vendor/wheels/tests/specs/cli/PackagesCommandHelpSpec.cfc +++ b/vendor/wheels/tests/specs/cli/PackagesCommandHelpSpec.cfc @@ -63,6 +63,34 @@ component extends="wheels.WheelsTest" { } }); + it("packages() hint metadata leads with `Add`, not the intercepted `Install` verb", () => { + var source = fileRead(ctx.modulePath); + + // LuCLI surfaces the `hint:` javadoc on the packages() function + // in auto-introspected help. Leading with "Install" nudges + // users toward `wheels packages install`, which never reaches + // this module. + expect(source contains "hint: Install, update, and list Wheels packages").toBeFalse( + "packages() hint still leads with `Install`. Lead with `Add` " + & "(the canonical verb) so auto-introspected help matches showHelp()." + ); + expect(source contains "hint: Add, update, and list Wheels packages").toBeTrue( + "packages() hint should lead with `Add, update, and list ...` " + & "and mention that the verb is `add`, not `install`." + ); + }); + + it("unknown-subcommand error points users at `wheels packages add`", () => { + var source = fileRead(ctx.modulePath); + + expect(source contains "Unknown packages subcommand").toBeTrue( + "Expected the packages() default branch to throw an unknown-subcommand error." + ); + expect(source contains "The install verb is `add` (not `install`): wheels packages add ").toBeTrue( + "The unknown-subcommand error should tell users the install verb is `add`." + ); + }); + }); } diff --git a/vendor/wheels/tests/specs/cli/RefreshVisualBaselinesPrPushSpec.cfc b/vendor/wheels/tests/specs/cli/RefreshVisualBaselinesPrPushSpec.cfc new file mode 100644 index 0000000000..bce9b018e3 --- /dev/null +++ b/vendor/wheels/tests/specs/cli/RefreshVisualBaselinesPrPushSpec.cfc @@ -0,0 +1,158 @@ +component extends="wheels.WheelsTest" { + + // Regression for issue ##3283. + // + // The final "Commit and push refreshed baselines" step in + // .github/workflows/refresh-visual-baselines.yml pushed the refreshed PNG(s) + // straight back to the dispatched branch with `git push origin "HEAD:$BRANCH"`. + // Once the `develop` branch ruleset started requiring "Changes must be made + // through a pull request", that direct push is rejected with + // `GH013: Repository rule violations found` and the workflow fails after doing + // all the expensive rebuild + screenshot work. + // + // The fix extracts delivery into tools/gh-open-refresh-baseline-pr.sh: + // 1. Commit the refreshed PNG(s), then try the direct push as a GUARDED + // fast path. On an unprotected branch (the workflow header's documented + // flow — dispatching on a PR's source branch) this keeps the exact + // pre-##3283 behaviour: the commit lands immediately. + // 2. If the push is rejected (GH013 on develop), fall back to a throwaway + // `chore/refresh-baseline-*` branch + `gh pr create` against the target + // branch. That needs `pull-requests: write` on the job (a + // direct-push-only job only has `contents: write`). + // 3. The fallback PR is deliberately NOT auto-merged: it is authored by + // the workflow's GITHUB_TOKEN, and GitHub never fires `pull_request` + // workflows for GITHUB_TOKEN-authored PRs, so the target branch's + // required checks would sit "Expected" forever and auto-merge would + // wedge silently. The sibling refresh-packages-baseline.yml documents + // the same gotcha and also leaves its PR for a human. + // + // Because the step's behaviour (git + gh side effects against a real + // checkout) cannot be exercised in a unit test, this spec pins the invariant + // with a static check of the workflow and the helper it calls. Whole-line + // shell/YAML comments are stripped first so the assertions only match + // EXECUTABLE code — a comment that merely mentions `gh pr create` must not + // satisfy the spec. + + function run() { + + describe("refresh-visual-baselines.yml survives a push-protected branch (issue ##3283)", () => { + + // expandPath("/wheels") resolves to vendor/wheels via the configured + // Lucee mapping; the repo root is two levels above. + var repoRoot = expandPath("/wheels/../.."); + var workflow = repoRoot & "/.github/workflows/refresh-visual-baselines.yml"; + var helper = repoRoot & "/tools/gh-open-refresh-baseline-pr.sh"; + + // Drop whole-line comments (` ## ...` in shell and YAML alike) so + // the it-blocks assert on executable lines only. Line-by-line filter + // on purpose — no global regex over the whole file. + var stripCommentLines = function(required string src) { + var lines = listToArray(src, chr(10), true); + var kept = []; + for (var line in lines) { + if (!reFind("^[ \t]*##", line)) { + arrayAppend(kept, line); + } + } + return arrayToList(kept, chr(10)); + }; + + it("hosts no push of its own — the workflow delegates delivery to the tools/ helper", () => { + expect(fileExists(workflow)).toBeTrue("Missing file: " & workflow); + var wfExec = stripCommentLines(fileRead(workflow)); + + // The classic direct push the develop ruleset rejects lived in a + // workflow `run:` block: git push origin "HEAD:$BRANCH" + // No `git push` of any shape belongs in the workflow now. + expect(reFindNoCase("git[[:space:]]+push", wfExec) == 0).toBeTrue( + "issue ##3283: refresh-visual-baselines.yml must NOT push from a workflow " + & "step (`git push origin ""HEAD:$BRANCH""` is what the develop ruleset " + & "rejects with GH013). Delivery belongs in " + & "tools/gh-open-refresh-baseline-pr.sh, which guards the push and falls " + & "back to a PR." + ); + expect(reFindNoCase("gh-open-refresh-baseline-pr\.sh", wfExec) > 0).toBeTrue( + "issue ##3283: refresh-visual-baselines.yml must invoke " + & "tools/gh-open-refresh-baseline-pr.sh to deliver the refreshed baseline(s)." + ); + }); + + it("grants the job pull-requests: write so the PR fallback can operate", () => { + var wfExec = stripCommentLines(fileRead(workflow)); + expect(reFindNoCase("pull-requests:[[:space:]]*write", wfExec) > 0).toBeTrue( + "issue ##3283: opening the fallback refresh PR needs `pull-requests: write` " + & "in the job `permissions:` block. Without it the `gh pr create` call " + & "fails on the default `contents: write`-only token." + ); + }); + + it("treats the direct push as a guarded fast path — a GH013 rejection can never fail the job", () => { + expect(fileExists(helper)).toBeTrue( + "Missing helper: " & helper & " — the commit/push/PR-fallback flow should " + & "live in a reviewable, reusable script the workflow calls." + ); + var helperExec = stripCommentLines(fileRead(helper)); + + // Every executable `git push ... HEAD:` must sit in an + // `if` condition (rejection falls through to the PR fallback + // instead of tripping `set -e`), and the fast path must exist. + var pushLines = []; + for (var line in listToArray(helperExec, chr(10), true)) { + if (reFindNoCase("git[[:space:]]+push[^\n]*HEAD:", line)) { + arrayAppend(pushLines, line); + } + } + expect(arrayLen(pushLines) > 0).toBeTrue( + "issue ##3283: the helper should keep the pre-##3283 direct push as a " + & "fast path for branches that allow it (feature-branch dispatch, the " + & "workflow header's documented flow)." + ); + for (var pushLine in pushLines) { + expect(reFindNoCase("^[ \t]*if[[:space:]]+git[[:space:]]+push", pushLine) > 0).toBeTrue( + "issue ##3283: every direct `git push ... HEAD:` in the helper " + & "must be `if`-guarded so a ruleset rejection (GH013) falls through to " + & "the PR fallback instead of failing the job under `set -e`. " + & "Unguarded line: " & pushLine + ); + } + }); + + it("falls back to an executable `gh pr create` and never enables auto-merge", () => { + var helperExec = stripCommentLines(fileRead(helper)); + var wfExec = stripCommentLines(fileRead(workflow)); + + expect(reFindNoCase("gh[[:space:]]+pr[[:space:]]+create", helperExec) > 0).toBeTrue( + "issue ##3283: when the direct push is rejected, the refreshed baseline(s) " + & "must be delivered via `gh pr create` against the target branch (executable " + & "code, not a comment)." + ); + + // GITHUB_TOKEN-authored PRs never trigger `pull_request` workflows + // (GitHub's recursive-trigger guard), so the required checks would + // sit ""Expected"" forever and `gh pr merge --auto` would wedge + // silently — the exact trap refresh-packages-baseline.yml documents. + var combined = helperExec & chr(10) & wfExec; + expect(reFindNoCase("gh[[:space:]]+pr[[:space:]]+merge[^\n]*--auto", combined) == 0).toBeTrue( + "issue ##3283: do not enable auto-merge on the fallback refresh PR. It is " + & "authored by the workflow's GITHUB_TOKEN, whose PRs never fire the " + & "required `pull_request` checks, so auto-merge can never complete — the " + & "PR must be left for a maintainer (see refresh-packages-baseline.yml's " + & "header for the same gotcha)." + ); + }); + + it("keeps re-runs safe by seeding the throwaway branch name with the run attempt", () => { + var helperExec = stripCommentLines(fileRead(helper)); + expect(reFindNoCase("chore/refresh-baseline-[^\n]*RUN_ID[^\n]*RUN_ATTEMPT", helperExec) > 0).toBeTrue( + "issue ##3283: the throwaway branch name must include both RUN_ID and " + & "RUN_ATTEMPT — a re-run of a failed job reuses the same run id, so a " + & "branch named only after RUN_ID collides with the leftover branch from " + & "attempt 1 and the `git push -u origin` fails non-fast-forward." + ); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/cli/ReloadRefusedNoticeParitySpec.cfc b/vendor/wheels/tests/specs/cli/ReloadRefusedNoticeParitySpec.cfc new file mode 100644 index 0000000000..c405db4ed8 --- /dev/null +++ b/vendor/wheels/tests/specs/cli/ReloadRefusedNoticeParitySpec.cfc @@ -0,0 +1,105 @@ +/** + * Issue ##3311 — dev-mode inline notice when ?reload=true is refused. + * + * The reload gate in the app template's onRequestStart() (fail-closed since + * ##3062) must RECORD why a requested reload did not fire, so the framework's + * debug bar (vendor/wheels/events/onrequestend/debug.cfm) can surface a + * development-only notice instead of a silent no-op. Recording lives in the + * template copies; message text and the development-environment gate live + * framework-side so wording can improve without template drift. + * + * Contract, which ALL FOUR same-lineage copies of public/Application.cfc must + * carry (same lineage as ReloadPasswordGateParitySpec.cfc): + * + * 1. A refused reload records request.wheels.reloadRefusedReason with one of + * exactly three reasons: "emptyPassword" (no non-empty reloadPassword is + * configured), "missingPasswordParam" (password configured but no + * password parameter supplied), "refused" (everything else). + * 2. NO ORACLE: wrong-password and rate-limited refusals must collapse into + * the single generic "refused" reason — the copies must not record a + * reason string that distinguishes them. + * + * Structural spec (no runtime): exercising the gate at runtime would + * applicationStop() the suite mid-run. Modeled on + * ReloadPasswordGateParitySpec.cfc. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("reload-refused notice recording parity (issue ##3311)", () => { + + // expandPath("/wheels") resolves to vendor/wheels via the + // configured Lucee mapping; the repo root is two levels above. + var repoRoot = expandPath("/wheels/../.."); + var targets = [ + "cli/lucli/templates/app/public/Application.cfc", + "public/Application.cfc", + "examples/tweet/public/Application.cfc", + "examples/starter-app/public/Application.cfc" + ]; + + for (var rel in targets) { + // Capture the loop variable so the closure body binds the + // current value, not the final iteration's value. + (function(relPath) { + + it("records all three refusal reasons in " & relPath, () => { + var absolute = repoRoot & "/" & relPath; + expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute); + var content = fileRead(absolute); + + expect( + content contains 'request.wheels.reloadRefusedReason = "emptyPassword"' + ).toBeTrue( + relPath & " must record reloadRefusedReason=emptyPassword when a " + & "reload is requested with no non-empty reloadPassword configured " + & "(issue ##3311)." + ); + expect( + content contains 'request.wheels.reloadRefusedReason = "missingPasswordParam"' + ).toBeTrue( + relPath & " must record reloadRefusedReason=missingPasswordParam when " + & "a password is configured but the request carried no password " + & "parameter (issue ##3311)." + ); + expect( + content contains 'request.wheels.reloadRefusedReason = "refused"' + ).toBeTrue( + relPath & " must record the generic reloadRefusedReason=refused for " + & "wrong-password/rate-limited attempts (issue ##3311)." + ); + }); + + it("keeps wrong-password and rate-limited refusals indistinguishable in " & relPath, () => { + var absolute = repoRoot & "/" & relPath; + expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute); + var content = fileRead(absolute); + + // The only assignments to the flag are the three contract reasons — + // no copy may grow a reason that leaks WHY the compare failed. + var assignments = REMatch("request\.wheels\.reloadRefusedReason\s*=\s*""[^""]*""", content); + expect(ArrayLen(assignments) == 3).toBeTrue( + relPath & " must assign reloadRefusedReason exactly three times " + & "(emptyPassword, missingPasswordParam, refused) — found " + & ArrayLen(assignments) & " (issue ##3311)." + ); + for (var assignment in assignments) { + expect( + REFindNoCase("wrong|incorrect|rate", assignment) == 0 + ).toBeTrue( + relPath & " records a refusal reason that distinguishes " + & "wrong-password from rate-limited — the notice must stay " + & "oracle-free (issue ##3311): " & assignment + ); + } + }); + + })(rel); + } + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/controller/AuthorizationSpec.cfc b/vendor/wheels/tests/specs/controller/AuthorizationSpec.cfc new file mode 100644 index 0000000000..b3ed18c6de --- /dev/null +++ b/vendor/wheels/tests/specs/controller/AuthorizationSpec.cfc @@ -0,0 +1,242 @@ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + describe("Authorization policy layer (wheels.Policy + authorize()/can()/policyScope())", () => { + + beforeEach(() => { + $savedPolicyPath = application.wheels.policyPath + $savedShowError = application.wheels.showErrorInformation + application.wheels.policyPath = "/wheels/tests/_assets/policies" + + author = g.model("author").findOne(where = "firstName = 'Per'", order = "id") + otherAuthor = g.model("author").findOne(where = "firstName = 'Tony'", order = "id") + post = g.model("post").findOne(where = "authorid = #author.id#", order = "id") + + // Fixture controller whose $currentUserForPolicy() override reads + // request.$policyTestUser (the documented app customization seam). + _controller = g.controller("authorization", {controller = "authorization", action = "update"}) + }) + + afterEach(() => { + application.wheels.policyPath = $savedPolicyPath + application.wheels.showErrorInformation = $savedShowError + StructDelete(request, "$policyTestUser") + }) + + describe("wheels.Policy base class", () => { + + it("default-denies every standard action", () => { + basePolicy = CreateObject("component", "wheels.Policy").init(user = {id = 1}, record = post) + + expect(basePolicy.index()).toBeFalse() + expect(basePolicy.show()).toBeFalse() + expect(basePolicy.new()).toBeFalse() + expect(basePolicy.create()).toBeFalse() + expect(basePolicy.edit()).toBeFalse() + expect(basePolicy.update()).toBeFalse() + expect(basePolicy.delete()).toBeFalse() + }) + + it("default-denies scope() with an injection-safe no-rows chain", () => { + basePolicy = CreateObject("component", "wheels.Policy").init(user = {id = 1}, record = "") + scoped = basePolicy.scope(g.model("post")) + + expect(g.model("post").count()).toBeGT(0) + expect(scoped.count()).toBe(0) + expect(scoped.findAll().recordCount).toBe(0) + }) + + it("default-denies through an app policy that overrides nothing", () => { + request.$policyTestUser = {id = author.id} + + expect(_controller.can("index", author)).toBeFalse() + expect(_controller.can("show", author)).toBeFalse() + expect(_controller.can("update", author)).toBeFalse() + expect(_controller.policyScope(g.model("author")).count()).toBe(0) + expect(() => _controller.authorize(record = author, action = "update")).toThrow( + type = "Wheels.NotAuthorized" + ) + }) + }) + + describe("authorize()", () => { + + it("returns the record when the policy allows", () => { + request.$policyTestUser = {id = author.id} + result = _controller.authorize(record = post, action = "update") + + expect(result.id).toBe(post.id) + expect(result.title).toBe(post.title) + }) + + it("throws Wheels.NotAuthorized when the policy denies", () => { + request.$policyTestUser = {id = otherAuthor.id} + + expect(() => _controller.authorize(record = post, action = "update")).toThrow( + type = "Wheels.NotAuthorized" + ) + }) + + it("defaults the action from params.action at call time", () => { + // _controller was created with params.action = "update". + request.$policyTestUser = {id = author.id} + result = _controller.authorize(post) + expect(result.id).toBe(post.id) + + request.$policyTestUser = {id = otherAuthor.id} + expect(() => _controller.authorize(post)).toThrow(type = "Wheels.NotAuthorized") + }) + + it("throws Wheels.Policy.MissingAction when no action can be resolved in development/testing", () => { + actionless = g.controller("authorization", {controller = "authorization"}) + request.$policyTestUser = {id = author.id} + + expect(() => actionless.authorize(post)).toThrow(type = "Wheels.Policy.MissingAction") + }) + + it("denies a guest (no user)", () => { + // No request.$policyTestUser -> the resolver returns "" (guest). + expect(() => _controller.authorize(record = post, action = "update")).toThrow( + type = "Wheels.NotAuthorized" + ) + }) + + it("denies a custom action the policy has no method for", () => { + request.$policyTestUser = {id = author.id} + + expect(() => _controller.authorize(record = post, action = "publish")).toThrow( + type = "Wheels.NotAuthorized" + ) + }) + + it("denies the boolean false a missed finder returns", () => { + request.$policyTestUser = {id = author.id} + + expect(() => _controller.authorize(record = false, action = "update")).toThrow( + type = "Wheels.NotAuthorized" + ) + }) + }) + + describe("can()", () => { + + it("returns true when the policy grants the action", () => { + request.$policyTestUser = {id = author.id} + + expect(_controller.can("update", post)).toBeTrue() + expect(_controller.can("index", post)).toBeTrue() + expect(_controller.can("show", post)).toBeTrue() + }) + + it("returns false when the policy denies the action", () => { + request.$policyTestUser = {id = otherAuthor.id} + + expect(_controller.can("update", post)).toBeFalse() + // Inherited default-deny from the base class. + expect(_controller.can("delete", post)).toBeFalse() + }) + + it("returns false for a guest on user-gated actions but true on public ones", () => { + expect(_controller.can("index", post)).toBeFalse() + expect(_controller.can("update", post)).toBeFalse() + expect(_controller.can("show", post)).toBeTrue() + }) + + it("returns false for a custom action the policy has no method for", () => { + request.$policyTestUser = {id = author.id} + + expect(_controller.can("publish", post)).toBeFalse() + }) + + it("returns false for an empty record", () => { + request.$policyTestUser = {id = author.id} + + expect(_controller.can("update")).toBeFalse() + }) + }) + + describe("policyScope()", () => { + + it("narrows the collection to the policy's scope", () => { + request.$policyTestUser = {id = author.id} + expected = g.model("post").count(where = "authorid = #author.id#") + scoped = _controller.policyScope(g.model("post")) + + expect(expected).toBeGT(0) + expect(g.model("post").count()).toBeGT(expected) + expect(scoped.count()).toBe(expected) + }) + + it("returns a chain that keeps composing", () => { + request.$policyTestUser = {id = author.id} + expected = g.model("post").count(where = "authorid = #author.id# AND status = 'published'") + scoped = _controller.policyScope(g.model("post")).where("status", "published") + + expect(expected).toBeGT(0) + expect(scoped.count()).toBe(expected) + }) + + it("default-denies (no rows) for a guest via the inherited base scope", () => { + expect(g.model("post").count()).toBeGT(0) + expect(_controller.policyScope(g.model("post")).count()).toBe(0) + }) + + it("throws Wheels.Policy.InvalidCollection for an in-flight chain in development/testing", () => { + request.$policyTestUser = {id = author.id} + builder = g.model("post").where("views", ">", 0) + + expect(() => _controller.policyScope(builder)).toThrow(type = "Wheels.Policy.InvalidCollection") + }) + }) + + describe("missing policy class", () => { + + it("throws Wheels.Policy.NotDefined in development/testing", () => { + request.$policyTestUser = {id = author.id} + comment = g.model("comment").findOne(order = "id") + + expect(() => _controller.can("update", comment)).toThrow(type = "Wheels.Policy.NotDefined") + expect(() => _controller.authorize(record = comment, action = "update")).toThrow( + type = "Wheels.Policy.NotDefined" + ) + expect(() => _controller.policyScope(g.model("comment"))).toThrow( + type = "Wheels.Policy.NotDefined" + ) + }) + + it("silently denies in production (showErrorInformation off)", () => { + request.$policyTestUser = {id = author.id} + comment = g.model("comment").findOne(order = "id") + application.wheels.showErrorInformation = false + + expect(_controller.can("update", comment)).toBeFalse() + expect(g.model("comment").count()).toBeGT(0) + expect(_controller.policyScope(g.model("comment")).count()).toBe(0) + }) + }) + + describe("identity resolution", () => { + + it("resolves a guest (empty string) through the default seam when nothing is registered", () => { + plain = g.controller("test", {controller = "test", action = "show"}) + + expect(plain.$currentUserForPolicy()).toBe("") + expect(plain.can("update", post)).toBeFalse() + expect(plain.can("show", post)).toBeTrue() + }) + }) + + describe("routable surface", () => { + + it("registers authorize/can/policyScope as protected controller methods", () => { + expect(ListFindNoCase(application.wheels.protectedControllerMethods, "authorize")).toBeGT(0) + expect(ListFindNoCase(application.wheels.protectedControllerMethods, "can")).toBeGT(0) + expect(ListFindNoCase(application.wheels.protectedControllerMethods, "policyScope")).toBeGT(0) + }) + }) + }) + } +} diff --git a/vendor/wheels/tests/specs/controller/SuperOverrideSpec.cfc b/vendor/wheels/tests/specs/controller/SuperOverrideSpec.cfc new file mode 100644 index 0000000000..8b2d03cf9b --- /dev/null +++ b/vendor/wheels/tests/specs/controller/SuperOverrideSpec.cfc @@ -0,0 +1,61 @@ +component extends="wheels.WheelsTest" { + + function run() { + g = application.wo + + // Regression for issue #3325 (from discussion #3323). + // + // The `super` convention was implemented asymmetrically. `Model.cfc`'s + // $integrateFunctions() registered the framework original as `super` whenever the + // mixin's name already existed on the target — i.e. whenever the app had overridden it. + // `Controller.cfc`'s did not: it only registered `super` for names a registered + // plugin/package mixin overrode. So an app that overrode a controller or view helper, + // exactly as the "Overriding Core Methods" guide documents, got nothing — and calling + // `superLinkTo()` was a 500. + describe("Tests that the super override convention", () => { + + it("registers super for an app-level controller/view helper override", () => { + c = g.controller(name = "superOverride") + + expect(StructKeyExists(c, "superLinkTo")).toBeTrue() + }) + + it("lets the override delegate to the framework original", () => { + c = g.controller(name = "superOverride") + + // the fixture returns "wrapped:" & superLinkTo(...), so a real anchor + // coming back proves the original ran rather than recursing into the override + result = c.linkTo(text = "Home", route = "root") + + expect(result).toStartWith("wrapped:") + expect(result).toInclude(" for a model override, unchanged", () => { + // the model side already behaved this way; pinned so the parity cannot + // regress from either direction + m = g.model("superOverride") + + expect(StructKeyExists(m, "superColumnNames")).toBeTrue() + expect(m.columnNames()).toStartWith("wrapped:") + }) + + it("adds no super keys to a controller that overrides nothing", () => { + // the else branch fires only on a genuine override, so the common case pays + // nothing — this runs on every request + c = g.controller(name = "test") + supers = [] + for (key in StructKeyArray(c)) { + if (Left(key, 5) == "super") { + ArrayAppend(supers, key) + } + } + + expect(ArrayLen(supers)).toBe(0) + }) + }) + } + +} diff --git a/vendor/wheels/tests/specs/controller/miscellaneousSpec.cfc b/vendor/wheels/tests/specs/controller/miscellaneousSpec.cfc index 0b635091bf..b5e248fc25 100644 --- a/vendor/wheels/tests/specs/controller/miscellaneousSpec.cfc +++ b/vendor/wheels/tests/specs/controller/miscellaneousSpec.cfc @@ -494,7 +494,17 @@ component extends="wheels.WheelsTest" { fileContent = FileRead(filePath) FileDelete(filePath) - expect(fileContent).toInclude(textBody & Chr(13) & Chr(10) & Chr(13) & Chr(10) & HTMLBody) + // Assert the blank line, not the bytes that encode it. sendEmail + // writes CRLFCRLF, but BoxLang's cffile write normalizes CRLF to LF + // on the way to disk — a byte-level probe showed a 10-byte + // "AAA\r\n\r\nBBB" payload landing as 8 bytes — so a literal + // CRLFCRLF needle failed on all five boxlang legs while passing on + // Lucee and Adobe (#3302). The line-ending encoding of a debug + // artifact is the engine's business; the blank line is ours. + normalized = Replace(fileContent, Chr(13) & Chr(10), Chr(10), "all") + normalized = Replace(normalized, Chr(13), Chr(10), "all") + + expect(normalized).toInclude(textBody & Chr(10) & Chr(10) & HTMLBody) }) it("sends single template email when layout is an empty string", () => { diff --git a/vendor/wheels/tests/specs/controller/paginationHandleCollisionSpec.cfc b/vendor/wheels/tests/specs/controller/paginationHandleCollisionSpec.cfc new file mode 100644 index 0000000000..84e024dd21 --- /dev/null +++ b/vendor/wheels/tests/specs/controller/paginationHandleCollisionSpec.cfc @@ -0,0 +1,139 @@ +/** + * Regression coverage for #3339. + * + * `setPagination()` / `pagination()` key themselves on a caller-supplied handle name (default + * `"query"`), which used to be written straight into `request.wheels`. CFML struct keys are + * case-insensitive, so a handle matching a framework-owned key collided with it in both + * directions: + * + * 1. Write: `setPagination(handle="tenant")` overwrote the resolved tenant context with a + * pagination struct. `handle="$queryCache"` did the same to the per-request finder cache. + * 2. Read: `pagination()` only validates the handle when `showErrorInformation` is on, so in + * production an unknown handle that happened to name a framework key returned that key's + * struct as though it were pagination state. + * + * Handles now live under the reserved `request.wheels.$pagination` sub-struct. Same fix shape as + * #3336, which moved the finder cache to `request.wheels.$queryCache`. + */ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo; + + describe("pagination handle / framework key collision (##3339)", () => { + + // The whole core suite runs inside a single request, so request.wheels is shared across + // spec files. Only ever remove this spec's own handles — deleting the $pagination + // namespace wholesale would destroy handles other specs set up. + ownHandles = "articles,comments,tenant,$queryCache,noSuchHandleXYZ"; + + // Ensure the namespace inline rather than calling g.$ensurePaginationStore(): a + // zero-argument dotted call in statement position breaks Adobe CF 2025's parser. + beforeEach(() => { + originalShowErr = application.wheels.showErrorInformation; + originalCacheSetting = application.wheels.cacheQueriesDuringRequest; + StructDelete(request.wheels, "tenant"); + if (!StructKeyExists(request.wheels, "$pagination")) { + request.wheels["$pagination"] = {}; + } + paginationStore = request.wheels["$pagination"]; + for (var h in ListToArray(ownHandles)) { + StructDelete(paginationStore, h, false); + } + }) + + afterEach(() => { + application.wheels.showErrorInformation = originalShowErr; + application.wheels.cacheQueriesDuringRequest = originalCacheSetting; + StructDelete(request.wheels, "tenant"); + if (!StructKeyExists(request.wheels, "$pagination")) { + request.wheels["$pagination"] = {}; + } + paginationStore = request.wheels["$pagination"]; + for (var h in ListToArray(ownHandles)) { + StructDelete(paginationStore, h, false); + } + }) + + it("stores handles under the reserved namespace, not the bare key", () => { + g.setPagination(totalRecords = 100, currentPage = 2, perPage = 10, handle = "articles"); + + expect(StructKeyExists(request.wheels, "$pagination")).toBeTrue(); + expect(StructKeyExists(request.wheels["$pagination"], "articles")).toBeTrue(); + expect(StructKeyExists(request.wheels, "articles")).toBeFalse(); + }) + + it("round-trips pagination data through the namespace", () => { + g.setPagination(totalRecords = 100, currentPage = 2, perPage = 10, handle = "articles"); + var pg = g.pagination("articles"); + + expect(pg.totalRecords).toBe(100); + expect(pg.currentPage).toBe(2); + expect(pg.perPage).toBe(10); + expect(pg.totalPages).toBe(10); + }) + + // Write direction — a handle named after a framework key must not clobber it. + it("does not overwrite resolved tenant context when a handle is named tenant", () => { + request.wheels.tenant = {id = "acme", dataSource = "tenant_acme", config = {}, "$locked" = true}; + + g.setPagination(totalRecords = 50, currentPage = 1, perPage = 25, handle = "tenant"); + + expect(IsDefined("request.wheels.tenant")).toBeTrue(); + expect(request.wheels.tenant.id).toBe("acme"); + expect(request.wheels.tenant.dataSource).toBe("tenant_acme"); + expect(g.$tenantDataSource()).toBe("tenant_acme"); + }) + + it("does not overwrite the finder cache namespace when a handle is named $queryCache", () => { + application.wheels.cacheQueriesDuringRequest = true; + model("author").findAll(where = "lastName = 'Djurner'"); + var cachedBefore = StructCount(request.wheels["$queryCache"]["author"]); + + g.setPagination(totalRecords = 50, currentPage = 1, perPage = 25, handle = "$queryCache"); + + expect(StructKeyExists(request.wheels["$queryCache"], "author")).toBeTrue(); + expect(StructCount(request.wheels["$queryCache"]["author"])).toBe(cachedBefore); + }) + + // Read direction — the case showErrorInformation hides in production. + it("does not return a framework struct for an unknown handle when errors are hidden", () => { + application.wheels.showErrorInformation = false; + request.wheels.tenant = {id = "acme", dataSource = "tenant_acme", config = {}, "$locked" = true}; + + // Pre-fix this returned the tenant struct as though it were pagination data. + // It must now fail to resolve rather than hand back foreign state. + var result = {returnedTenant = false, threw = false}; + try { + var pg = g.pagination("tenant"); + result.returnedTenant = IsStruct(pg) && StructKeyExists(pg, "dataSource"); + } catch (any e) { + result.threw = true; + } + + expect(result.returnedTenant).toBeFalse(); + expect(result.threw).toBeTrue(); + }) + + it("still throws Wheels.QueryHandleNotFound for an unknown handle in development", () => { + application.wheels.showErrorInformation = true; + + expect(function() { + g.pagination("noSuchHandleXYZ"); + }).toThrow("Wheels.QueryHandleNotFound"); + }) + + it("keeps distinct handles isolated from each other", () => { + g.setPagination(totalRecords = 100, currentPage = 1, perPage = 10, handle = "articles"); + g.setPagination(totalRecords = 30, currentPage = 3, perPage = 5, handle = "comments"); + + expect(g.pagination("articles").totalRecords).toBe(100); + expect(g.pagination("comments").totalRecords).toBe(30); + expect(g.pagination("comments").currentPage).toBe(3); + }) + + }) + + } +} diff --git a/vendor/wheels/tests/specs/controller/requestSpec.cfc b/vendor/wheels/tests/specs/controller/requestSpec.cfc index 87e081ebb1..9f1e74829c 100644 --- a/vendor/wheels/tests/specs/controller/requestSpec.cfc +++ b/vendor/wheels/tests/specs/controller/requestSpec.cfc @@ -154,13 +154,22 @@ component extends="wheels.WheelsTest" { describe("Tests that pagination", () => { beforeEach(() => { - request.wheels["myhandle"] = {test = "true"} + // Ensure the namespace inline rather than calling application.wo.$ensurePaginationStore(): + // a zero-argument dotted call in statement position breaks Adobe CF 2025's parser. + if (!StructKeyExists(request.wheels, "$pagination")) { + request.wheels["$pagination"] = {} + } + paginationStore = request.wheels["$pagination"] + paginationStore["myhandle"] = {test = "true"} params = {controller = "dummy", action = "dummy"} _controller = application.wo.controller("dummy", params) }) afterEach(() => { - StructDelete(request.wheels, "myhandle", false) + // Delete only this spec's handle. The whole core suite runs in one request, so + // wiping the shared $pagination namespace would destroy other specs' handles. + paginationStore = request.wheels["$pagination"] + StructDelete(paginationStore, "myhandle", false) }) it("handle exists", () => { diff --git a/vendor/wheels/tests/specs/database/AdapterIdentityTemplateSpec.cfc b/vendor/wheels/tests/specs/database/AdapterIdentityTemplateSpec.cfc index 5e09d4863b..1341fae9c8 100644 --- a/vendor/wheels/tests/specs/database/AdapterIdentityTemplateSpec.cfc +++ b/vendor/wheels/tests/specs/database/AdapterIdentityTemplateSpec.cfc @@ -18,11 +18,23 @@ component extends="wheels.WheelsTest" { expect(probe.$parseInsertColumnList("INSERT INTO users (id")).toBe(""); }); - it("parses via the regex branch on engines flagged BoxLang", () => { - var probe = CreateObject("component", "wheels.tests._assets.adapters.BaseProbe"); - probe.boxlangMode = true; + // $parseInsertColumnList used to fork on $isBoxLangEngine(). The + // result must now be identical no matter what that flag reports, + // so pin both settings to the same expectation — a reintroduced + // fork fails here rather than only on the boxlang matrix legs. + it("parses identically regardless of the BoxLang engine flag", () => { var insertSql = "INSERT INTO users ([id], ""name"",#Chr(10)#age) VALUES (1,'x',2)"; - expect(probe.$parseInsertColumnList(insertSql)).toBe("id,name,age"); + for (var flag in [false, true]) { + var probe = CreateObject("component", "wheels.tests._assets.adapters.BaseProbe"); + probe.boxlangMode = flag; + expect(probe.$parseInsertColumnList(insertSql)).toBe("id,name,age"); + } + }); + + it("preserves spaces inside a quoted identifier", () => { + var probe = CreateObject("component", "wheels.tests._assets.adapters.BaseProbe"); + var insertSql = "INSERT INTO orders ([order date], id) VALUES ('x',1)"; + expect(probe.$parseInsertColumnList(insertSql)).toBe("order date,id"); }); }); diff --git a/vendor/wheels/tests/specs/database/CockroachDBTransactionSpec.cfc b/vendor/wheels/tests/specs/database/CockroachDBTransactionSpec.cfc index 61694f3073..41b6f0d600 100644 --- a/vendor/wheels/tests/specs/database/CockroachDBTransactionSpec.cfc +++ b/vendor/wheels/tests/specs/database/CockroachDBTransactionSpec.cfc @@ -48,7 +48,16 @@ component extends="wheels.WheelsTest" { }); it("updateAll with rollback does not persist changes", () => { - transaction action="begin" { + // The isolation level must be declared here even though this + // outer transaction does not otherwise need one. updateAll's + // transaction="rollback" routes through invokeWithTransaction, + // which opens its own cftransaction with isolation="read_committed" + // — and Adobe rejects a nested cftransaction whose isolation + // level differs from its parent's ("Nested cftransaction tag + // should specify same isolation level as the parent"). Lucee and + // BoxLang do not enforce that, so an undeclared parent only fails + // on the two Adobe legs of the matrix (#3302). + transaction action="begin" isolation="read_committed" { g.model("tag").updateAll(name = "CRDBTemp", transaction = "rollback"); var changed = g.model("tag").findAll(where = "name = 'CRDBTemp'"); expect(changed.recordCount).toBe(0); diff --git a/vendor/wheels/tests/specs/database/TimestampRoundTripSpec.cfc b/vendor/wheels/tests/specs/database/TimestampRoundTripSpec.cfc new file mode 100644 index 0000000000..244601b912 --- /dev/null +++ b/vendor/wheels/tests/specs/database/TimestampRoundTripSpec.cfc @@ -0,0 +1,102 @@ +/** + * A `cf_sql_timestamp` written to a datetime column must read back in one of the + * two shapes the framework knows how to interpret (#3302). + * + * There is no engine-independent guarantee that it reads back as a date. SQLite + * has no real DATETIME type, and on Lucee 7 + sqlite-jdbc the value returns as + * raw epoch milliseconds — a probe here read `1785873308685` back from a + * `cf_sql_timestamp` write. `RateLimiter.$secondsSince()` already encodes that + * reality: `IsDate()` first, otherwise treat the value as epoch milliseconds + * against `GetTickCount()`. + * + * So the contract is a disjunction, and this spec asserts exactly it: the value + * is a CFML date, or it is a number of milliseconds close enough to now to be an + * epoch timestamp. Anything else lands in `$secondsSince`'s numeric branch as + * garbage, and every caller that branches on elapsed time silently misbehaves: + * + * - `RateLimiter` token buckets read as permanently empty or permanently full. + * - `Migrator`'s `applied_at` renders as nothing in `migrate info` / `doctor`. + * + * Both fail on adobe2023 + oracle — three `RateLimiterDatabaseSpec` legs and two + * `SchemaEnrichmentSpec` legs, all consistent with one shared cause. Asserting + * that cause directly beats chasing five symptoms, and the failure message + * prints the value, which none of the five do. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("cf_sql_timestamp round-trip (##3302)", () => { + + it("reads back as a CFML date or as epoch milliseconds", () => { + var written = DateAdd("n", -37, Now()); + + queryExecute( + "DELETE FROM c_o_r_e_bulkitems WHERE code = :code", + {code: {value: "TS-ROUNDTRIP", cfsqltype: "cf_sql_varchar"}}, + {datasource: application.wheels.dataSourceName} + ); + queryExecute( + "INSERT INTO c_o_r_e_bulkitems (code, name, quantity, createdat) + VALUES (:code, :name, :quantity, :createdat)", + { + code: {value: "TS-ROUNDTRIP", cfsqltype: "cf_sql_varchar"}, + name: {value: "TimestampRoundTrip", cfsqltype: "cf_sql_varchar"}, + quantity: {value: 1, cfsqltype: "cf_sql_integer"}, + createdat: {value: written, cfsqltype: "cf_sql_timestamp"} + }, + {datasource: application.wheels.dataSourceName} + ); + + var row = queryExecute( + "SELECT createdat FROM c_o_r_e_bulkitems WHERE code = :code", + {code: {value: "TS-ROUNDTRIP", cfsqltype: "cf_sql_varchar"}}, + {datasource: application.wheels.dataSourceName} + ); + expect(row.recordCount).toBe(1); + + var readBack = row.createdat; + var shape = IsDate(readBack) ? "date" : (IsNumeric(readBack) ? "numeric" : "neither"); + + // Report the value, not just the verdict. "Expected [NO] to be true" + // is what the five downstream failures already say, and it names + // nothing at all. + expect(shape).notToBe( + "neither", + "A cf_sql_timestamp round-tripped as something $secondsSince() cannot " + & "read: wrote [" & DateTimeFormat(written, "yyyy-mm-dd HH:nn:ss") + & "], read back [" & readBack & "]. Every framework path that stores a " + & "timestamp and later measures elapsed time against it — RateLimiter's " + & "token bucket, the migrator's applied_at — is unreliable here." + ); + + // Whichever shape it is, it has to still mean the time that was + // written. Reproduce $secondsSince()'s own computation rather than + // reconstructing a date from the epoch value: both branches yield + // "seconds since the stored moment", which is timezone-free, so the + // comparison holds wherever the suite runs. + var elapsed = IsDate(readBack) + ? DateDiff("s", readBack, Now()) + : Int((GetTickCount() - readBack) / 1000); + var expected = DateDiff("s", written, Now()); + + expect(Abs(elapsed - expected)).toBeLT( + 120, + "The stored timestamp came back as a #shape# that does not resolve to " + & "the time written: wrote [" & DateTimeFormat(written, "yyyy-mm-dd HH:nn:ss") + & "], read back [" & readBack & "]. $secondsSince() would report " + & elapsed & "s elapsed where " & expected & "s is correct." + ); + + queryExecute( + "DELETE FROM c_o_r_e_bulkitems WHERE code = :code", + {code: {value: "TS-ROUNDTRIP", cfsqltype: "cf_sql_varchar"}}, + {datasource: application.wheels.dataSourceName} + ); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/dispatch/AppRunnerTestFormatSpec.cfc b/vendor/wheels/tests/specs/dispatch/AppRunnerTestFormatSpec.cfc new file mode 100644 index 0000000000..78faa5e563 --- /dev/null +++ b/vendor/wheels/tests/specs/dispatch/AppRunnerTestFormatSpec.cfc @@ -0,0 +1,113 @@ +component extends="wheels.WheelsTest" { + + function run() { + + describe("app-runner output format resolution (issue 3251)", () => { + + // Issue #3251 (item 1): `/wheels/app/tests?format=html` — which is + // also the no-format default — historically emitted application/json. + // A user opening that URL in a browser reasonably expects the + // TestBox-style HTML report the core runner (`/wheels/core/tests`) + // renders. resolveFormat() centralizes the format-to-output decision + // so the html / no-format branch can be marked rendersHtml=true; the + // app runner then falls through to html.cfm (type="App", a branch + // html.cfm already supports) exactly like the core runner does. + // + // Recognized formats are html | json | txt | junit (plus the + // no-format default). An UNrecognized value (an empty string or an + // arbitrary token like "xml") resolves to recognized=false: the app + // runner emits nothing for it, preserving the historical behavior. + // html.cfm must NOT be rendered for an arbitrary url.format — its + // dev-tools navigation builds format-toggle links from url.format and + // the framework's response content-negotiation 500s on Adobe when the + // format is unknown. + + it("renders HTML when url has no format key (the no-format default)", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = {}); + expect(resolved.recognized).toBeTrue(); + expect(resolved.rendersHtml).toBeTrue(); + expect(resolved.format).toBe("html"); + expect(resolved.contentType).toBe("text/html"); + }); + + it("renders HTML for format=html", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = { format: "html" }); + expect(resolved.recognized).toBeTrue(); + expect(resolved.rendersHtml).toBeTrue(); + expect(resolved.contentType).toBe("text/html"); + }); + + it("uses the JSON reporter for the HTML branch (html.cfm needs the JSON payload to render)", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + expect(resolver.resolveFormat(url = { format: "html" }).reporter) + .toBe("wheels.wheelstest.system.reports.JSONReporter"); + }); + + it("emits JSON (not HTML) for format=json", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = { format: "json" }); + expect(resolved.recognized).toBeTrue(); + expect(resolved.rendersHtml).toBeFalse(); + expect(resolved.format).toBe("json"); + expect(resolved.contentType).toBe("application/json"); + expect(resolved.reporter).toBe("wheels.wheelstest.system.reports.JSONReporter"); + }); + + it("emits text/plain for format=txt via the Text reporter", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = { format: "txt" }); + expect(resolved.recognized).toBeTrue(); + expect(resolved.rendersHtml).toBeFalse(); + expect(resolved.format).toBe("txt"); + expect(resolved.contentType).toBe("text/plain"); + expect(resolved.reporter).toBe("wheels.wheelstest.system.reports.TextReporter"); + }); + + it("emits text/xml for format=junit via the ANTJUnit reporter", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = { format: "junit" }); + expect(resolved.recognized).toBeTrue(); + expect(resolved.rendersHtml).toBeFalse(); + expect(resolved.format).toBe("junit"); + expect(resolved.contentType).toBe("text/xml"); + expect(resolved.reporter).toBe("wheels.wheelstest.system.reports.ANTJUnitReporter"); + }); + + it("matches the format token case-insensitively", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + expect(resolver.resolveFormat(url = { format: "JSON" }).format).toBe("json"); + expect(resolver.resolveFormat(url = { format: "Txt" }).format).toBe("txt"); + expect(resolver.resolveFormat(url = { format: "HTML" }).rendersHtml).toBeTrue(); + }); + + it("trims surrounding whitespace before matching", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + expect(resolver.resolveFormat(url = { format: " junit " }).format).toBe("junit"); + }); + + it("does NOT render HTML for an empty format value (preserves the historical no-output behavior)", () => { + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = { format: "" }); + expect(resolved.recognized).toBeFalse(); + expect(resolved.rendersHtml).toBeFalse(); + }); + + it("does NOT render HTML for an unrecognized format token (avoids the Adobe html.cfm 500)", () => { + // Rendering html.cfm for an arbitrary url.format 500s on Adobe + // (the dev-tools nav reads url.format and response negotiation + // chokes on the unknown format). An unrecognized token must + // resolve to recognized=false so the app runner emits nothing, + // exactly as it did before this fix. + var resolver = new wheels.tests._assets.dispatch.TestFormatResolver(); + var resolved = resolver.resolveFormat(url = { format: "xml" }); + expect(resolved.recognized).toBeFalse(); + expect(resolved.rendersHtml).toBeFalse(); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/dispatch/HtmlReportFunctionDeclarationGuardSpec.cfc b/vendor/wheels/tests/specs/dispatch/HtmlReportFunctionDeclarationGuardSpec.cfc new file mode 100644 index 0000000000..98bd3281ee --- /dev/null +++ b/vendor/wheels/tests/specs/dispatch/HtmlReportFunctionDeclarationGuardSpec.cfc @@ -0,0 +1,80 @@ +/** + * Structural cross-engine guard for issue #3251 (item 1). + * + * vendor/wheels/tests/html.cfm renders the TestBox HTML report and is included + * into the cached Public.cfc singleton by both the core runner + * (vendor/wheels/tests/runner.cfm) and the app runner + * (vendor/wheels/tests/app-runner.cfm). On Adobe ColdFusion a *named* function + * declaration in an included .cfm leaks into the component scope, so the second + * request that includes the template throws "Routines cannot be declared more + * than once" — a hard HTTP 500. html.cfm originally declared + * `function processNestedSuites()`, which 500'd `/wheels/core/tests?format=html` + * and `/wheels/app/tests?format=html` on every Adobe engine. + * + * The fix declares the helper as a variables-scoped function EXPRESSION + * (`variables.processNestedSuites = function(){...}`), the same pattern the core + * runner already uses for its helpers to dodge Adobe's + * DuplicateFunctionDefinitionException. CI exercises the runners with + * `format=json`, never the `format=html` render path, so a regression here is + * invisible to the normal suite — hence this source-level guard. + * + * Scan rules (Anti-Pattern 14 spirit, line-anchored on purpose): + * - Only html.cfm is scanned (a .cfm view included into a component). + * - A named declaration matches `function (`; a function EXPRESSION + * (`= function(`) and the JS IIFE in the inline