From 8214afee1e6ea483796c0a4fedac527f94b8a2b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 19:37:38 -0400 Subject: [PATCH] fix(extension): normalize FC URL so credential push doesn't 405 The stored apiUrl was required to already carry the `/api` suffix, since api.js builds requests as `${baseUrl}/credentials`. The options label read "FC base URL", so entering the instance root -- the natural reading -- sent every request one path segment short: POST /credentials hit the Vue SPA catch-all and came back 405, and GET /extension/manifest 404'd. Worse, Test Connection reported success on it: the catch-all answers GET /credentials with 200 HTML, so `r.ok` was true and the only affordance meant to catch this misconfiguration actively masked it. Normalize instead of validate (rules 92, 26): - New lib/url.js: normalizeApiUrl / webRootFromApiUrl, one source shared by the background client and the options page. Accepts either the instance root or the API root. - api.js normalizes on read, so configs already stored in the broken form heal themselves without the operator reopening Settings. - options.js stores the canonical form, echoes back what it saved, and the test now asserts a JSON content-type -- killing the false green. - 404/405 in request() now names the URL and points at the setting. - Options label/placeholder state that both forms work. Version 1.0.9 -> 1.0.10 in BOTH manifest.json and package.json; build.yml resolves the release version from package.json, and a stale value there would hit the cached ext-1.0.9 asset and republish the old XPI unsigned against the new code. Refs #2393 Co-Authored-By: Claude Opus 5 (1M context) --- extension/lib/api.js | 19 ++++++++++++++----- extension/lib/url.js | 32 ++++++++++++++++++++++++++++++++ extension/manifest.json | 4 ++-- extension/options/options.html | 10 +++++++--- extension/options/options.js | 28 +++++++++++++++++++++++----- extension/package.json | 2 +- 6 files changed, 79 insertions(+), 16 deletions(-) create mode 100644 extension/lib/url.js diff --git a/extension/lib/api.js b/extension/lib/api.js index a6b71b2..e66ea93 100644 --- a/extension/lib/api.js +++ b/extension/lib/api.js @@ -11,7 +11,10 @@ class FabledCuratorAPI { async init() { const cfg = await browser.storage.local.get(['apiUrl', 'apiKey']); - this.baseUrl = cfg.apiUrl || null; + // Normalize on READ, not just on save: configs stored before the options + // page started normalizing are missing the `/api` suffix, and this heals + // them without the operator having to reopen Settings. + this.baseUrl = normalizeApiUrl(cfg.apiUrl) || null; this.apiKey = cfg.apiKey || null; return this.isConfigured(); } @@ -50,6 +53,13 @@ class FabledCuratorAPI { } catch { message = `HTTP ${response.status}: ${response.statusText}`; } + // 404/405 from FC almost always means the request never reached the JSON + // API — it fell through to the SPA catch-all, which serves HTML on GET + // and rejects everything else. Say so, rather than making the operator + // decode "Method Not Allowed" on an endpoint that plainly allows POST. + if (response.status === 404 || response.status === 405) { + message += ` — ${url} isn't the FC API. Check the FC URL in settings.`; + } const err = new Error(message); err.status = response.status; throw err; @@ -96,11 +106,10 @@ class FabledCuratorAPI { return this.request('GET', '/extension/manifest'); } - // The web/SPA root: baseUrl with the trailing slash + `/api` suffix stripped. - // Where the Vue router (artist pages) and the served XPI live, NOT the JSON - // API. Used by OPEN_ARTIST_PAGE + the self-update check. + // The web/SPA root: where the Vue router (artist pages) and the served XPI + // live, NOT the JSON API. Used by OPEN_ARTIST_PAGE + the self-update check. webRoot() { - return (this.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, ''); + return webRootFromApiUrl(this.baseUrl); } // Connection test = the cheapest read with auth. diff --git a/extension/lib/url.js b/extension/lib/url.js new file mode 100644 index 0000000..2329adf --- /dev/null +++ b/extension/lib/url.js @@ -0,0 +1,32 @@ +/** + * Canonical FC endpoint derivation, shared by the background client and the + * options page so a URL entered either way behaves identically. + * + * FC serves two things on one origin: the JSON API under `/api`, and the Vue + * SPA from the root. `api.js` builds requests as `${baseUrl}/credentials`, so + * the stored base URL has to carry the `/api` suffix. + */ + +/** + * Accept what an operator would naturally type — the instance root + * (`http://curator.example.com`) or the API root (`.../api`) — and return the + * API root either way. + * + * Worth normalizing rather than validating: a root-form URL doesn't fail + * loudly, it lands on the SPA catch-all, which answers `GET /credentials` with + * 200 HTML and rejects `POST /credentials` with 405. The operator sees a + * working Test Connection and a broken export. + */ +function normalizeApiUrl(raw) { + const trimmed = (raw || '').trim().replace(/\/+$/, ''); + if (!trimmed) return ''; + return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`; +} + +/** + * The SPA root — where the Vue router (artist pages) and the served XPI live, + * NOT the JSON API. Accepts either input form, same as normalizeApiUrl. + */ +function webRootFromApiUrl(raw) { + return normalizeApiUrl(raw).replace(/\/api$/i, ''); +} diff --git a/extension/manifest.json b/extension/manifest.json index 4348ba1..e763e3f 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "FabledCurator", - "version": "1.0.9", + "version": "1.0.10", "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.", "browser_specific_settings": { @@ -46,7 +46,7 @@ }, "background": { - "scripts": ["lib/platforms.js", "lib/cookies.js", "lib/api.js", "background/background.js"] + "scripts": ["lib/platforms.js", "lib/cookies.js", "lib/url.js", "lib/api.js", "background/background.js"] }, "options_ui": { diff --git a/extension/options/options.html b/extension/options/options.html index 27e5136..9af858c 100644 --- a/extension/options/options.html +++ b/extension/options/options.html @@ -21,9 +21,12 @@

FabledCurator extension

- - -
Find this on FC → Settings → Maintenance → Browser extension.
+ + +
+ Your FabledCurator address — with or without the trailing /api; both work. + Find it on FC → Settings → Maintenance → Browser extension. +
@@ -36,6 +39,7 @@ + diff --git a/extension/options/options.js b/extension/options/options.js index 4798183..e453273 100644 --- a/extension/options/options.js +++ b/extension/options/options.js @@ -8,7 +8,7 @@ document.addEventListener('DOMContentLoaded', async () => { }); async function save() { - const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, ''); + const apiUrl = normalizeApiUrl(document.getElementById('api-url').value); const apiKey = document.getElementById('api-key').value.trim(); if (!apiUrl || !apiKey) { showStatus('Both fields are required.', 'err'); @@ -16,11 +16,14 @@ async function save() { } await browser.storage.local.set({ apiUrl, apiKey }); await browser.storage.local.remove(['lastConnectionTest', 'lastConnectionStatus']); - showStatus('Saved.', 'ok'); + // Show what was actually stored — the operator may have typed the instance + // root and it was normalized to the API root. + document.getElementById('api-url').value = apiUrl; + showStatus(`Saved — using ${apiUrl}`, 'ok'); } async function test() { - const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, ''); + const apiUrl = normalizeApiUrl(document.getElementById('api-url').value); const apiKey = document.getElementById('api-key').value.trim(); if (!apiUrl || !apiKey) { showStatus('Fill both fields first.', 'err'); @@ -31,8 +34,23 @@ async function test() { method: 'GET', headers: { 'X-Extension-Key': apiKey }, }); - if (r.ok) showStatus(`Connected — HTTP ${r.status}.`, 'ok'); - else showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err'); + if (!r.ok) { + showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err'); + return; + } + // A 200 is NOT sufficient. If the URL resolves to the Vue SPA instead of + // the JSON API, the catch-all route returns 200 with an HTML document — + // which used to report "Connected" on a config that could not POST at all. + const contentType = r.headers.get('content-type') || ''; + if (!contentType.includes('json')) { + showStatus( + `${apiUrl} answered with ${contentType || 'no content-type'}, not JSON ` + + '— that looks like the FC web UI rather than its API.', + 'err', + ); + return; + } + showStatus(`Connected to ${apiUrl} — HTTP ${r.status}.`, 'ok'); } catch (e) { showStatus(`Cannot reach ${apiUrl}: ${e.message}`, 'err'); } diff --git a/extension/package.json b/extension/package.json index 16a9858..8230440 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "fabledcurator-extension", - "version": "1.0.9", + "version": "1.0.10", "private": true, "description": "Firefox extension for FabledCurator", "scripts": {