Skip to content

fix(mobile-api): self-host Swagger UI (no CDN) — /api/v1/docs was blank#197

Open
fabiodalez-dev wants to merge 4 commits into
mainfrom
fix/swagger-ui-vendor-no-cdn
Open

fix(mobile-api): self-host Swagger UI (no CDN) — /api/v1/docs was blank#197
fabiodalez-dev wants to merge 4 commits into
mainfrom
fix/swagger-ui-vendor-no-cdn

Conversation

@fabiodalez-dev

@fabiodalez-dev fabiodalez-dev commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Problem

GET /api/v1/docs (the Mobile API Swagger UI page) rendered blank: only a .gitkeep had shipped under public/assets/swagger-ui/, so the page fell back to the jsDelivr CDN — which is blocked behind an egress firewall / offline.

Fix

Vendor Swagger UI 5.18.2 locally and drop the CDN fallback entirely: assetsBaseUrl() now always returns the local public/assets/swagger-ui/ path. Removed the dead SWAGGER_UI_VERSION const and docRoot() method. Added a Playwright regression test (tests/swagger-docs.spec.js) asserting the page references local assets and never a CDN, and that the bundle actually renders the API operations.

Follow-up fixes from the review (commits 2376fea0, e05e8ffb)

  • Escaping/XSS$cssUrl/$jsUrl (Host-derived) are now htmlspecialchars(ENT_QUOTES,'UTF-8')-escaped before going into href/src, matching $title/$docUrl (the /api/ routes are exempt from the canonical-host redirect, so the Host is attacker-influenceable).
  • Release gate — added the two vendored assets to create-release.sh CRITICAL_FILES, so a release can't silently ship a blank docs page.
  • JS-context escaping$docUrl now uses json_encode(JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) (correct escaper for a JS string), not htmlspecialchars.
  • No more blank page — if the bundle fails to load, the inline script renders a diagnostic with a link to the raw openapi.json instead of an empty #swagger-ui.

Verification

php -l + PHPStan level 5 clean; browser check on /api/v1/docs → SwaggerUIBundle boots, 29 operation blocks render, 0 JS errors; swagger-docs.spec.js 4/4. No runtime CDN dependency remains.

GET /api/v1/docs rendered blank wherever the jsDelivr CDN is blocked (egress
firewall): only public/assets/swagger-ui/.gitkeep shipped, so the controller
fell back to cdn.jsdelivr.net for swagger-ui-bundle.js/css and the bundle never
loaded. Vendor swagger-ui-dist 5.18.2 (bundle + css) into the repo so it ships
in the release ZIP, and make SwaggerUiController always serve the local copy —
no CDN reference at all. The openapi.json spec was already fine.

Verified locally: /api/v1/docs references /assets/swagger-ui/* (no jsdelivr),
the assets serve 200, and Swagger UI renders the 29 operations.
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

SwaggerUiController usa asset Swagger UI locali, aggiorna l’escaping delle URL e aggiunge verifiche di release e test per gli asset vendorizzati.

Changes

Asset Swagger UI locali

Layer / File(s) Summary
Doc e base URL locale
storage/plugins/mobile-api/src/Controllers/SwaggerUiController.php
La doc comment descrive asset Swagger UI self-hosted e assetsBaseUrl() restituisce sempre il percorso locale senza fallback CDN.
Escaping e fallback JS
storage/plugins/mobile-api/src/Controllers/SwaggerUiController.php
buildHtml() escape le URL degli asset, serializza correttamente l’URL OpenAPI per JavaScript e mostra un messaggio se SwaggerUIBundle non è disponibile.
Verifiche di release e docs
scripts/create-release.sh, tests/swagger-docs.spec.js
Lo script di release include gli asset Swagger UI tra i file critici e il test Playwright verifica endpoint OpenAPI, asset locali e rendering della UI.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 60.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Il titolo riassume correttamente il cambio principale: Swagger UI ora è self-hosted senza CDN per risolvere /api/v1/docs vuota.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/swagger-ui-vendor-no-cdn

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

tests/swagger-docs.spec.js asserts /api/v1/docs is self-hosted: openapi.json is
a valid OpenAPI 3 spec, the docs page references /assets/swagger-ui/* and never
a CDN (jsdelivr/cdnjs/unpkg), the local bundle+css are actually served, and
Swagger UI renders the operations from the local bundle. Skips cleanly when the
Mobile API plugin is not active.
@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@fabiodalez-dev

fabiodalez-dev commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

Code review

Branch: fix/swagger-ui-vendor-no-cdnmain
PR: #197 (open)

Automated 6-lens review. The core of the PR — dropping the jsDelivr CDN fallback and serving Swagger UI from vendored local assets only — is correct. Below are findings on pre-existing code that the CDN removal makes more relevant.

🔧 To fix (confirmed — escaping/XSS, rule #3)

SwaggerUiController.php:62-74,104 · $cssUrl/$jsUrl (and thus $assetsBaseUrl, derived from the Host header via baseUrl()) are interpolated into href="{$cssUrl}" and <script src="{$jsUrl}"> without htmlspecialchars(ENT_QUOTES,'UTF-8'), while $title and $docUrl in the same method ARE escaped — a real inconsistency and a CLAUDE.md rule #3 violation.

  • The Host is not normalized on this route: the canonical-host redirect in public/index.php explicitly skips /api/ paths → an arbitrary Host reaches the controller intact. A value with " or > could break out of the attribute (reflected XSS).
  • Fix: $cssUrl = htmlspecialchars($assetsBaseUrl.'/swagger-ui.css', ENT_QUOTES, 'UTF-8'); and likewise for $jsUrl (lines 62-63). Behavior-preserving for legitimate hosts.

ℹ️ To consider (real, below the confirmation threshold)

  • scripts/create-release.sh:156-166 — with the CDN fallback gone, /api/v1/docs now hard-depends on the two vendored public/assets/swagger-ui/* files, but they are not in CRITICAL_FILES (TinyMCE is). git archive ships them today (tracked, not export-ignored), so no immediate breakage; but a future export-ignore/removal would build a release with a blank docs page and a green build, detectable only via E2E. Suggestion: add the two paths to CRITICAL_FILES, as already done for TinyMCE.
  • SwaggerUiController.php:110$docUrl is HTML-escaped (htmlspecialchars) but used inside a JS string (url: "{$docUrl}"): wrong escaper for the context. Low risk (the value is scheme/host/path-derived, not user input), but CLAUDE.md rule Add Z39.50/SRU Server Plugin and Auto-Install System #3 prescribes json_encode(..., JSON_HEX_TAG) for PHP→JS.

No findings

  • UX — one minor finding (blank page with no diagnostic if the bundle fails to load), classified below threshold on a developer-facing docs page.
  • The 2 vendored assets (swagger-ui-bundle.js, swagger-ui.css) are upstream minified library files, out of scope for line-by-line review. docRoot() and SWAGGER_UI_VERSION removed with no other references (verified). The new E2E test covers the "self-hosted, no CDN" case.

…red assets in release

Two follow-ups from the self-hosted Swagger UI change, both low-risk:

- SwaggerUiController::buildHtml() now wraps $cssUrl/$jsUrl (built from the
  Host-derived $assetsBaseUrl) in htmlspecialchars(ENT_QUOTES,'UTF-8'), matching
  the sibling $title/$docUrl. The /api/v1/docs route is exempt from the
  canonical-host redirect, so an arbitrary Host header reaches the page; without
  escaping a Host containing `"`/`>` could break out of the href/src attribute
  (reflected injection) and it violated the "escape all HTML attributes" rule.
  Legitimate hosts are unaffected (htmlspecialchars is identity on a clean URL).

- create-release.sh: add public/assets/swagger-ui/swagger-ui-bundle.js and
  swagger-ui.css to CRITICAL_FILES. With the CDN fallback gone, /api/v1/docs hard-
  depends on these vendored files; the release gate guarded TinyMCE but not these,
  so a future export-ignore/removal could ship a blank docs page with a green build.

Verified: php -l + PHPStan level 5 clean; bash -n clean; both assets present (1.4 MB
/ 152 KB) and git-tracked so the new gate entries pass; escaping confirmed to
neutralize a malicious Host while leaving normal asset URLs unchanged.
@fabiodalez-dev

fabiodalez-dev commented Jun 26, 2026

Copy link
Copy Markdown
Owner Author

Fixes applied (commit 2376fea0):

  • 🔧 Escaping/XSSSwaggerUiController::buildHtml() now wraps $cssUrl/$jsUrl (Host-derived) in htmlspecialchars(ENT_QUOTES,'UTF-8'), like $title/$docUrl. A malicious Host is neutralized, legitimate URLs unchanged.
  • 🔧 Releasecreate-release.sh: added public/assets/swagger-ui/swagger-ui-bundle.js and swagger-ui.css to CRITICAL_FILES (already shipped via git archive; now the gate verifies them).

Verified: php -l clean, PHPStan level 5 clean, bash -n clean, assets present + tracked (1.4 MB / 152 KB).

Still open, not requested in this round: $docUrl in a JS context (json_encode(JSON_HEX_TAG) instead of htmlspecialchars) and the blank page with no diagnostic if the bundle fails to load — both low-risk.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/swagger-docs.spec.js`:
- Around line 15-26: The skip gate in the swagger-docs Playwright spec currently
depends on probing GET /api/v1/openapi.json in test.beforeAll, which can hide
regressions by skipping the whole spec when that endpoint breaks. Remove this
availability probe from the beforeAll/beforeEach flow and make the guard rely
only on CI-injected E2E environment signals, consistent with the other
Playwright specs using E2E_ADMIN_EMAIL and E2E_ADMIN_PASS; keep the logic in
test.beforeEach and the apiAvailable-related setup aligned with that pattern.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b5c54c20-6cf2-4a66-8ca2-241d2abbbf66

📥 Commits

Reviewing files that changed from the base of the PR and between e2d710f and 2376fea.

📒 Files selected for processing (3)
  • scripts/create-release.sh
  • storage/plugins/mobile-api/src/Controllers/SwaggerUiController.php
  • tests/swagger-docs.spec.js

Comment on lines +15 to +26
test.beforeAll(async ({ request }) => {
try {
const res = await request.get(`${BASE}/api/v1/openapi.json`);
apiAvailable = res.status() === 200;
} catch {
apiAvailable = false;
}
});

test.beforeEach(() => {
test.skip(!apiAvailable, 'Mobile API plugin not active / app not installed');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Non usare l’endpoint sotto test come condizione di skip.

Se la regressione rompe GET /api/v1/openapi.json su Line 17, apiAvailable diventa false e da Line 25 in poi l’intero spec viene saltato invece di fallire. Così il test non protegge più proprio il bug che dovrebbe intercettare. Il gate deve dipendere solo dai segnali d’ambiente che CI inietta per gli E2E, oppure questo probe va rimosso del tutto.

Diff proposto
 const { test, expect } = require('`@playwright/test`');

 const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';
-
-// Public endpoints (no bearer token needed) but they only exist when the
-// bundled Mobile API plugin is active. Probe once and skip cleanly otherwise.
-let apiAvailable = true;
+const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL;
+const ADMIN_PASS = process.env.E2E_ADMIN_PASS;

 test.describe.serial('Mobile API — Swagger UI docs (self-hosted, no CDN)', () => {
-  test.beforeAll(async ({ request }) => {
-    try {
-      const res = await request.get(`${BASE}/api/v1/openapi.json`);
-      apiAvailable = res.status() === 200;
-    } catch {
-      apiAvailable = false;
-    }
-  });
-
   test.beforeEach(() => {
-    test.skip(!apiAvailable, 'Mobile API plugin not active / app not installed');
+    test.skip(!ADMIN_EMAIL || !ADMIN_PASS, 'Credenziali E2E admin non configurate');
   });

Based on learnings, i Playwright E2E in questo repo devono essere gated da variabili d’ambiente che CI inietta, e negli UI-only spec il guard deve usare solo E2E_ADMIN_EMAIL e E2E_ADMIN_PASS.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test.beforeAll(async ({ request }) => {
try {
const res = await request.get(`${BASE}/api/v1/openapi.json`);
apiAvailable = res.status() === 200;
} catch {
apiAvailable = false;
}
});
test.beforeEach(() => {
test.skip(!apiAvailable, 'Mobile API plugin not active / app not installed');
});
const { test, expect } = require('`@playwright/test`');
const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL;
const ADMIN_PASS = process.env.E2E_ADMIN_PASS;
test.describe.serial('Mobile API — Swagger UI docs (self-hosted, no CDN)', () => {
test.beforeEach(() => {
test.skip(!ADMIN_EMAIL || !ADMIN_PASS, 'Credenziali E2E admin non configurate');
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/swagger-docs.spec.js` around lines 15 - 26, The skip gate in the
swagger-docs Playwright spec currently depends on probing GET
/api/v1/openapi.json in test.beforeAll, which can hide regressions by skipping
the whole spec when that endpoint breaks. Remove this availability probe from
the beforeAll/beforeEach flow and make the guard rely only on CI-injected E2E
environment signals, consistent with the other Playwright specs using
E2E_ADMIN_EMAIL and E2E_ADMIN_PASS; keep the logic in test.beforeEach and the
apiAvailable-related setup aligned with that pattern.

Source: Learnings

…asset-load failure

Two remaining Swagger UI docs-page follow-ups, both low-risk:

- buildHtml() now JSON-encodes $openApiUrl with json_encode(JSON_HEX_TAG |
  JSON_UNESCAPED_SLASHES) instead of htmlspecialchars(). The value is consumed
  inside a JS string literal (SwaggerUIBundle url:), where HTML escaping is the
  wrong escaper (it leaves backslashes untouched); json_encode is the correct
  JS-context escaper and JSON_HEX_TAG blocks a </script> breakout. It already
  emits the surrounding quotes, so the heredoc drops them (url: {$docUrl}).

- The inline script now guards `typeof SwaggerUIBundle === 'undefined'` and
  renders a diagnostic (with a link to the raw openapi.json) into #swagger-ui
  instead of leaving a blank page. Self-hosted with no CDN fallback, a missing
  vendored bundle would otherwise show only the header bar with no explanation.

Verified: php -l + PHPStan level 5 clean; the generated inline JS is well-formed
for normal URLs and neutralizes a malicious Host (\" and < escapes); browser
check on /api/v1/docs — SwaggerUIBundle boots, 29 operation blocks render, no JS
errors.
@fabiodalez-dev

fabiodalez-dev commented Jun 26, 2026

Copy link
Copy Markdown
Owner Author

Fixed the last two as well (commit e05e8ffb):

  • 🔧 $docUrl in a JS context — now json_encode($openApiUrl, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) instead of htmlspecialchars (the right escaper for a JS string; JSON_HEX_TAG blocks a </script> breakout). The heredoc uses url: {$docUrl} (json_encode already supplies the quotes).
  • 🔧 Blank page → diagnostic — if SwaggerUIBundle is undefined (bundle not loaded), the script now renders a message with a link to the raw openapi.json instead of leaving #swagger-ui empty.

Verified: php -l clean, PHPStan L5 clean, and a real-browser check on /api/v1/docs → SwaggerUIBundle boots, 29 operation blocks rendered, 0 JS errors. Malicious Host input correctly neutralized (\", <).

All four review findings are now closed.

@fabiodalez-dev fabiodalez-dev changed the title fix(mobile-api): Swagger UI self-hosted (niente CDN) — /api/v1/docs era vuoto fix(mobile-api): self-host Swagger UI (no CDN) — /api/v1/docs was blank Jun 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant