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) <noreply@anthropic.com>
This commit is contained in:
+14
-5
@@ -11,7 +11,10 @@ class FabledCuratorAPI {
|
|||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const cfg = await browser.storage.local.get(['apiUrl', 'apiKey']);
|
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;
|
this.apiKey = cfg.apiKey || null;
|
||||||
return this.isConfigured();
|
return this.isConfigured();
|
||||||
}
|
}
|
||||||
@@ -50,6 +53,13 @@ class FabledCuratorAPI {
|
|||||||
} catch {
|
} catch {
|
||||||
message = `HTTP ${response.status}: ${response.statusText}`;
|
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);
|
const err = new Error(message);
|
||||||
err.status = response.status;
|
err.status = response.status;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -96,11 +106,10 @@ class FabledCuratorAPI {
|
|||||||
return this.request('GET', '/extension/manifest');
|
return this.request('GET', '/extension/manifest');
|
||||||
}
|
}
|
||||||
|
|
||||||
// The web/SPA root: baseUrl with the trailing slash + `/api` suffix stripped.
|
// The web/SPA root: where the Vue router (artist pages) and the served XPI
|
||||||
// Where the Vue router (artist pages) and the served XPI live, NOT the JSON
|
// live, NOT the JSON API. Used by OPEN_ARTIST_PAGE + the self-update check.
|
||||||
// API. Used by OPEN_ARTIST_PAGE + the self-update check.
|
|
||||||
webRoot() {
|
webRoot() {
|
||||||
return (this.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
|
return webRootFromApiUrl(this.baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection test = the cheapest read with auth.
|
// Connection test = the cheapest read with auth.
|
||||||
|
|||||||
@@ -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, '');
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "FabledCurator",
|
"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.",
|
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||||
|
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"background": {
|
"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": {
|
"options_ui": {
|
||||||
|
|||||||
@@ -21,9 +21,12 @@
|
|||||||
<body>
|
<body>
|
||||||
<h1>FabledCurator extension</h1>
|
<h1>FabledCurator extension</h1>
|
||||||
|
|
||||||
<label for="api-url">FC base URL</label>
|
<label for="api-url">FC instance URL</label>
|
||||||
<input id="api-url" type="url" placeholder="http://curator.example.com/api" />
|
<input id="api-url" type="url" placeholder="http://curator.example.com" />
|
||||||
<div class="hint">Find this on FC → Settings → Maintenance → Browser extension.</div>
|
<div class="hint">
|
||||||
|
Your FabledCurator address — with or without the trailing <code>/api</code>; both work.
|
||||||
|
Find it on FC → Settings → Maintenance → Browser extension.
|
||||||
|
</div>
|
||||||
|
|
||||||
<label for="api-key">Extension API key</label>
|
<label for="api-key">Extension API key</label>
|
||||||
<input id="api-key" type="password" placeholder="paste from FC Settings card" />
|
<input id="api-key" type="password" placeholder="paste from FC Settings card" />
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
|
|
||||||
<div id="status" class="status" style="display:none;"></div>
|
<div id="status" class="status" style="display:none;"></div>
|
||||||
|
|
||||||
|
<script src="../lib/url.js"></script>
|
||||||
<script src="options.js"></script>
|
<script src="options.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
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();
|
const apiKey = document.getElementById('api-key').value.trim();
|
||||||
if (!apiUrl || !apiKey) {
|
if (!apiUrl || !apiKey) {
|
||||||
showStatus('Both fields are required.', 'err');
|
showStatus('Both fields are required.', 'err');
|
||||||
@@ -16,11 +16,14 @@ async function save() {
|
|||||||
}
|
}
|
||||||
await browser.storage.local.set({ apiUrl, apiKey });
|
await browser.storage.local.set({ apiUrl, apiKey });
|
||||||
await browser.storage.local.remove(['lastConnectionTest', 'lastConnectionStatus']);
|
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() {
|
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();
|
const apiKey = document.getElementById('api-key').value.trim();
|
||||||
if (!apiUrl || !apiKey) {
|
if (!apiUrl || !apiKey) {
|
||||||
showStatus('Fill both fields first.', 'err');
|
showStatus('Fill both fields first.', 'err');
|
||||||
@@ -31,8 +34,23 @@ async function test() {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'X-Extension-Key': apiKey },
|
headers: { 'X-Extension-Key': apiKey },
|
||||||
});
|
});
|
||||||
if (r.ok) showStatus(`Connected — HTTP ${r.status}.`, 'ok');
|
if (!r.ok) {
|
||||||
else showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err');
|
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) {
|
} catch (e) {
|
||||||
showStatus(`Cannot reach ${apiUrl}: ${e.message}`, 'err');
|
showStatus(`Cannot reach ${apiUrl}: ${e.message}`, 'err');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fabledcurator-extension",
|
"name": "fabledcurator-extension",
|
||||||
"version": "1.0.9",
|
"version": "1.0.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Firefox extension for FabledCurator",
|
"description": "Firefox extension for FabledCurator",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user