diff --git a/README.md b/README.md index 34df50ce..4d954ef9 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Node 22 is required. ```bash npm install -npm run dev # http://localhost:5175/feuerwerk/dist/; API calls are proxied to production +npm run dev # http://localhost:5175/; API and data calls are proxied to production npm run check # typecheck + lint + format:check + unit tests; the gate CI runs too npm run build # writes dist/ and regenerates index.html; the folder is what nginx serves ``` diff --git a/api/lib/Auth.php b/api/lib/Auth.php index bf8be378..a980153b 100644 --- a/api/lib/Auth.php +++ b/api/lib/Auth.php @@ -109,19 +109,29 @@ final class Auth return (string) ($_COOKIE[SessionStore::COOKIE] ?? ''); } + /** + * The cookie has no `Path`: the browser takes the folder of the API as it sees it, which is + * `/feuerwerk/api` on nockal.com and `/api` on feuerwerk.nockal.com. nginx sees the second as + * the first, so no path this side writes down would be right for both. The editor's pages and + * the data never needed the cookie. + * + * Before that, the cookie was set for the whole site (`/feuerwerk/`). Such an old one is ended + * with every new one, or it would still sign a browser in after signing out. + */ private function setCookie(string $value, int $expires): void { - setcookie(SessionStore::COOKIE, $value, [ + $options = [ 'expires' => $expires, - 'path' => self::basePath(), 'httponly' => true, 'samesite' => 'Lax', 'secure' => ($_SERVER['HTTPS'] ?? '') !== '', - ]); + ]; + setcookie(SessionStore::COOKIE, $value, $options); + setcookie(SessionStore::COOKIE, '', ['expires' => time() - 3600, 'path' => self::basePath()] + $options); } /** - * The path the editor is served under (`/feuerwerk/`), read off the script rather than + * The path the old cookie was set for (`/feuerwerk/`), read off the script rather than * written down: the same checkout answers under a different prefix in CI. */ private static function basePath(): string diff --git a/src/core/site.ts b/src/core/site.ts index e7d9cdc5..d76c2feb 100644 --- a/src/core/site.ts +++ b/src/core/site.ts @@ -1,5 +1,15 @@ -/** The path the site is served under: the editor, the play page, the API and every data file. */ -export const SITE_PATH = '/feuerwerk/'; +/** + * The path the site is served under: the editor, the play page, the API and every data file. It + * is the folder of the open page, so one build answers at https://nockal.com/feuerwerk/ and at + * https://feuerwerk.nockal.com/ alike. Away from a page (Node, the tests) it is the nginx path. + */ +export const SITE_PATH = sitePath(typeof location === 'undefined' ? undefined : location.pathname); + +/** The folder of a page path: `/feuerwerk/play.html` and `/feuerwerk/` give `/feuerwerk/`. */ +export function sitePath(pagePath: string | undefined): string { + if (pagePath === undefined) return '/feuerwerk/'; + return pagePath.slice(0, pagePath.lastIndexOf('/') + 1); +} /** The API's one entry point. */ export const API_ENDPOINT = `${SITE_PATH}api/index.php`; diff --git a/tests/core/site.test.ts b/tests/core/site.test.ts index 1de1556c..e34caa83 100644 --- a/tests/core/site.test.ts +++ b/tests/core/site.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { API_ENDPOINT, dataPath, moveOldStoredKeys } from '../../src/core/site'; +import { API_ENDPOINT, dataPath, moveOldStoredKeys, sitePath } from '../../src/core/site'; function memoryStore(initial: Record) { const items = new Map(Object.entries(initial)); @@ -21,6 +21,14 @@ describe('site', () => { expect(dataPath('textures')).toBe('/feuerwerk/data/textures'); }); + it('takes the path from the folder of the open page', () => { + expect(sitePath('/feuerwerk/')).toBe('/feuerwerk/'); + expect(sitePath('/feuerwerk/index.html')).toBe('/feuerwerk/'); + expect(sitePath('/feuerwerk/play.html')).toBe('/feuerwerk/'); + expect(sitePath('/')).toBe('/'); + expect(sitePath('/play.html')).toBe('/'); + }); + it('moves the keys stored under the old name and leaves the others alone', () => { const store = memoryStore({ 'game-maker.panel-sizes': '{"left":300}', diff --git a/vite.config.ts b/vite.config.ts index e97dd672..76839928 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,27 +1,38 @@ -import { copyFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig, type Plugin } from 'vite'; import { publishPlugins } from './tools/lib/plugins.mjs'; -import { SITE_PATH } from './src/core/site'; const __dirname = dirname(fileURLToPath(import.meta.url)); /** - * The workspace folder itself is what nginx serves under https://nockal.com/feuerwerk/. + * The workspace folder itself is what nginx serves under https://nockal.com/feuerwerk/, and the + * same folder answers at https://feuerwerk.nockal.com/. So the build knows no path: its base is + * relative, and the pages find the API and the data from where they are (src/core/site.ts). * The Vite source pages live in `web/`: `index.html` is the editor, `play.html` a released game * (#266). The build writes hashed assets into `dist/` and this plugin copies the built pages to the * folder root, so that the site URLs load the production bundles while `api/` and `data/` sit next - * to them. + * to them. One folder up, their `./assets/` becomes `./dist/assets/`. */ const PAGES = ['index.html', 'play.html']; function publishPages(): Plugin { + let outDir = ''; return { name: 'feuerwerk:publish-pages', apply: 'build', + configResolved(config) { + outDir = resolve(config.root, config.build.outDir); + }, closeBundle() { - for (const page of PAGES) copyFileSync(resolve(__dirname, 'dist', page), resolve(__dirname, page)); + const folder = basename(outDir); + for (const page of PAGES) { + const html = readFileSync(resolve(outDir, page), 'utf8'); + const published = html.replaceAll('"./assets/', `"./${folder}/assets/`); + if (published === html) throw new Error(`${page}: no ./assets/ link to move`); + writeFileSync(resolve(outDir, '..', page), published); + } }, }; } @@ -79,7 +90,7 @@ function shipPlugins(): Plugin { export default defineConfig({ root: 'web', - base: `${SITE_PATH}dist/`, + base: './', publicDir: false, plugins: [contentSecurityPolicy(), publishPages(), shipPlugins()], build: { @@ -95,11 +106,12 @@ export default defineConfig({ }, server: { port: 5175, - // During development the PHP API is served by the production nginx; proxy it. + // During development the PHP API and the data are served by the production nginx; proxy them. proxy: { - [`${SITE_PATH}api`]: { + '^/(api|data)/': { target: 'https://nockal.com', changeOrigin: true, + rewrite: (path) => `/feuerwerk${path}`, }, }, },