Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e255076b5c |
@@ -78,6 +78,26 @@ end-turn button changes its wording when every card held is a train.
|
||||
|
||||
**Both published replays were retired by the rules change and re-recorded.** 623 tests pass.
|
||||
|
||||
### The deploy script was broken by a host upgrade
|
||||
|
||||
Not part of the playtest report — found trying to publish this build. Every deploy died with
|
||||
`login failed: 404 404 page not found`, because the File Browser host has been upgraded to
|
||||
**FileBrowser Quantum**, a fork whose API differs from the v2.63 one `deploy-web.ts` was written
|
||||
against. Three things moved at once, each fatal on its own:
|
||||
|
||||
1. **Auth is a session COOKIE**, not a JWT in the response body sent back as `X-Auth:`.
|
||||
2. **The password is a header** — `X-Password`, URL-encoded — not a JSON body field.
|
||||
3. **The path is a query parameter** (`?path=`), and every resource call must also name a
|
||||
**`source`**: Quantum can serve several named stores and refuses any call that does not say which
|
||||
("no source provided"). The v2.63 API had no such concept.
|
||||
|
||||
Rewritten against the running instance's own bundle rather than guessed — the same discipline the
|
||||
v2.63 version was written with — and each path confirmed against the live host by response code: an
|
||||
endpoint that exists answers a bad password with **401**, one that does not answers **404**. The
|
||||
source is discovered from `GET /api/settings/sources`; one configured source is used silently, and
|
||||
several makes the script stop and list them rather than deploy the site into the wrong store.
|
||||
`FB_SOURCE` overrides it, and `FB_OTP` carries a two-factor code.
|
||||
|
||||
---
|
||||
|
||||
## 0.4.9e — 2026-08-22
|
||||
|
||||
+92
-31
@@ -1,11 +1,29 @@
|
||||
/**
|
||||
* Build the solitaire site and push it to a FileBrowser instance.
|
||||
*
|
||||
* Written against File Browser v2.63's REST API, read from its own bundle rather than guessed:
|
||||
* REWRITTEN FOR **FileBrowser Quantum** (2026-08-23). The host was upgraded from File Browser v2.63
|
||||
* to the Quantum fork, whose API is different in three ways at once, and every deploy failed with
|
||||
* `login failed: 404 404 page not found` — the old `/api/login` simply is not there any more.
|
||||
*
|
||||
* POST /api/login {username, password, recaptcha} -> JWT as plain text
|
||||
* POST /api/resources/<dir>/ X-Auth: <jwt> -> create a directory
|
||||
* POST /api/resources/<file>?override=true X-Auth: <jwt>, body = bytes -> upload
|
||||
* Read from the running instance's own bundle rather than guessed, the same way the v2.63 version
|
||||
* was (`/public/static/assets/index-*.js`, gzipped — pipe it through `gunzip` before grepping), and
|
||||
* each path confirmed against the live host by the response code: an endpoint that exists answers a
|
||||
* bad password with **401**, one that does not answers **404**.
|
||||
*
|
||||
* POST /api/auth/login?username=<u>&recaptcha=
|
||||
* headers X-Password: <urlencoded>, X-Secret: <otp or empty> -> sets a session COOKIE
|
||||
* GET /api/settings/sources -> the named sources
|
||||
* POST /api/resources?path=<p>&source=<s>&isDir=true -> create a directory
|
||||
* POST /api/resources?path=<p>&source=<s>&override=true body=bytes -> upload
|
||||
*
|
||||
* THREE THINGS MOVED, and each would break on its own:
|
||||
* 1. AUTH IS A COOKIE, not an `X-Auth: <jwt>` header. Login returns no usable token in its body;
|
||||
* the session arrives in `Set-Cookie` and every later request has to carry it back.
|
||||
* 2. THE PASSWORD IS A HEADER, `X-Password`, URL-encoded — not a JSON body field.
|
||||
* 3. THE PATH IS A QUERY PARAMETER, `?path=`, not part of the URL, and every resource call also
|
||||
* needs a **`source`** naming which configured store to write to. Quantum throws "no source
|
||||
* provided" without it. `FB_SOURCE` names it; left unset, the sole configured source is used,
|
||||
* and if there is more than one this stops and lists them rather than guessing.
|
||||
*
|
||||
* File Browser is the STORE, not the server — Start9 Pages serves the uploaded folder as the site.
|
||||
* So the job here is simply to land the built files in the right folder, intact.
|
||||
@@ -18,6 +36,8 @@
|
||||
* Optional:
|
||||
* FB_URL default https://phoenix.local:58157
|
||||
* FB_DEST default websites/stationmaster — the folder Start9 Pages serves from
|
||||
* FB_SOURCE which configured source to write to; discovered automatically when there is one
|
||||
* FB_OTP the one-time code, if the account has two-factor enabled
|
||||
* FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host)
|
||||
* SITE_URL default https://65.78.82.12:54697/ — the public address Start9 Pages serves at
|
||||
* --dry-run list what would be sent, contact nothing
|
||||
@@ -44,6 +64,8 @@ const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replac
|
||||
const DEST = `/${(process.env['FB_DEST'] ?? 'websites/stationmaster').replace(/^\/+|\/+$/g, '')}`;
|
||||
const USER = process.env['FB_USER'] ?? '';
|
||||
const PASS = process.env['FB_PASS'] ?? '';
|
||||
const OTP = process.env['FB_OTP'] ?? '';
|
||||
const SOURCE_ENV = process.env['FB_SOURCE'] ?? '';
|
||||
const DRY = process.argv.includes('--dry-run');
|
||||
|
||||
/**
|
||||
@@ -75,37 +97,80 @@ const CONTENT_TYPES: Record<string, string> = {
|
||||
'.txt': 'text/plain',
|
||||
};
|
||||
|
||||
/**
|
||||
* Log in and return the session cookie every later request must carry.
|
||||
*
|
||||
* The password goes in a HEADER and URL-encoded, which is Quantum's own client does
|
||||
* (`X-Password: encodeURIComponent(password)`). The body carries nothing useful on success — the
|
||||
* session is in `Set-Cookie`, so a deploy that ignored the cookie would authenticate and then be
|
||||
* rejected by every upload.
|
||||
*/
|
||||
async function login(): Promise<string> {
|
||||
const res = await fetch(`${URL_BASE}/api/login`, {
|
||||
const url = `${URL_BASE}/api/auth/login?username=${encodeURIComponent(USER)}&recaptcha=`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: USER, password: PASS, recaptcha: '' }),
|
||||
headers: { 'X-Password': encodeURIComponent(PASS), 'X-Secret': OTP },
|
||||
});
|
||||
const body = await res.text();
|
||||
if (!res.ok) throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
|
||||
if (!body.trim()) throw new Error('login returned an empty token');
|
||||
return body.trim();
|
||||
if (!res.ok) {
|
||||
// 401 here is a wrong username/password; 404 would mean this build has moved the API again.
|
||||
throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
|
||||
}
|
||||
const cookies = res.headers.getSetCookie();
|
||||
if (cookies.length === 0) throw new Error('login succeeded but set no session cookie');
|
||||
return cookies.map((c) => c.split(';')[0]).join('; ');
|
||||
}
|
||||
|
||||
async function makeDir(jwt: string, path: string): Promise<void> {
|
||||
// Trailing slash is what marks a directory in this API. A 409 means it already exists, which is
|
||||
// the normal case on every deploy after the first.
|
||||
const res = await fetch(`${URL_BASE}/api/resources${encodePath(path)}/`, {
|
||||
/**
|
||||
* WHICH STORE TO WRITE TO. Quantum can serve several named sources and refuses any resource call
|
||||
* that does not name one ("no source provided"), which is the parameter the v2.63 API had no
|
||||
* concept of. One configured source is the normal case and is used without asking; more than one is
|
||||
* ambiguous, and guessing would silently deploy the site into the wrong store.
|
||||
*/
|
||||
async function resolveSource(cookie: string): Promise<string> {
|
||||
if (SOURCE_ENV) return SOURCE_ENV;
|
||||
const res = await fetch(`${URL_BASE}/api/settings/sources`, { headers: { cookie } });
|
||||
if (!res.ok) throw new Error(`could not list sources: ${res.status} ${await res.text()}`);
|
||||
const names = Object.keys((await res.json()) ?? {});
|
||||
if (names.length === 1) return names[0]!;
|
||||
if (names.length === 0) throw new Error('the server reports no sources at all');
|
||||
throw new Error(`several sources configured (${names.join(', ')}) — pick one with FB_SOURCE=<name>`);
|
||||
}
|
||||
|
||||
function resourceUrl(source: string, path: string, extra: Record<string, string>): string {
|
||||
const params = new URLSearchParams({ path, source, ...extra });
|
||||
return `${URL_BASE}/api/resources?${params}`;
|
||||
}
|
||||
|
||||
async function makeDir(cookie: string, source: string, path: string): Promise<void> {
|
||||
const res = await fetch(resourceUrl(source, path, { isDir: 'true' }), {
|
||||
method: 'POST',
|
||||
headers: { 'X-Auth': jwt },
|
||||
headers: { cookie },
|
||||
});
|
||||
if (!res.ok && res.status !== 409) {
|
||||
throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
if (res.ok) return;
|
||||
/**
|
||||
* "Already there" is the normal case on every deploy after the first, and Quantum is not
|
||||
* consistent about which code it reports it with. So the two failures worth stopping for are
|
||||
* named — a rejected session, and a server that broke — and every other 4xx is treated as the
|
||||
* directory already existing. A directory that genuinely is not there fails loudly at the upload
|
||||
* a moment later, which is a better place to find out than a guess here.
|
||||
*/
|
||||
const fatal = res.status === 401 || res.status === 403 || res.status >= 500;
|
||||
if (fatal) throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function upload(jwt: string, localPath: string, remotePath: string): Promise<void> {
|
||||
async function upload(
|
||||
cookie: string,
|
||||
source: string,
|
||||
localPath: string,
|
||||
remotePath: string,
|
||||
): Promise<void> {
|
||||
const bytes = readFileSync(localPath);
|
||||
const ext = remotePath.slice(remotePath.lastIndexOf('.'));
|
||||
const res = await fetch(`${URL_BASE}/api/resources${encodePath(remotePath)}?override=true`, {
|
||||
const res = await fetch(resourceUrl(source, remotePath, { override: 'true' }), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Auth': jwt,
|
||||
cookie,
|
||||
'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream',
|
||||
'Content-Length': String(bytes.byteLength),
|
||||
},
|
||||
@@ -114,11 +179,6 @@ async function upload(jwt: string, localPath: string, remotePath: string): Promi
|
||||
if (!res.ok) throw new Error(`upload ${remotePath} failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
/** Encode each segment but keep the separators, so a path stays a path. */
|
||||
function encodePath(p: string): string {
|
||||
return p.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log('building…');
|
||||
@@ -147,15 +207,16 @@ if (DRY) {
|
||||
);
|
||||
}
|
||||
|
||||
const jwt = await login();
|
||||
console.log('logged in');
|
||||
const cookie = await login();
|
||||
const source = await resolveSource(cookie);
|
||||
console.log(`logged in — writing to source "${source}"`);
|
||||
|
||||
await makeDir(jwt, DEST);
|
||||
for (const d of dirs) await makeDir(jwt, `${DEST}/${d}`);
|
||||
await makeDir(cookie, source, DEST);
|
||||
for (const d of dirs) await makeDir(cookie, source, `${DEST}/${d}`);
|
||||
|
||||
let done = 0;
|
||||
for (const f of files) {
|
||||
await upload(jwt, join(dist, f), `${DEST}/${f}`);
|
||||
await upload(cookie, source, join(dist, f), `${DEST}/${f}`);
|
||||
done++;
|
||||
console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user