1 parent
ffbf603bc8
commit
4de0a01464
6 files changed
+87
-44
No files matched your search
+4
-2
@@ -208,8 +208,10 @@ Browser clients are static files published by the host, and must match the
|
|||||||
server's API paths:
|
server's API paths:
|
||||||
|
|
||||||
- **Web client:** copy `internal/server/webserver/chess-client-web/`
|
- **Web client:** copy `internal/server/webserver/chess-client-web/`
|
||||||
(`index.html`, `app.js`, `style.css`) to the site directory. Without the
|
(`index.html`, `app.js`, `style.css`) to the site directory. It takes its
|
||||||
embedded `/config` endpoint, it uses the `/chess` API prefix.
|
API base from its script element, `<script src="app.js" data-api="…">`,
|
||||||
|
which the embedded server sets from `-web-api-url`; without one, as copied,
|
||||||
|
it uses the `/chess` API prefix.
|
||||||
- **WASM terminal client (if published):** `make wasm` (xterm.js is committed
|
- **WASM terminal client (if published):** `make wasm` (xterm.js is committed
|
||||||
under `lib/`), then copy `web/chess-client-wasm/`. It derives its API base
|
under `lib/`), then copy `web/chess-client-wasm/`. It derives its API base
|
||||||
as `<origin>/chess`. Its API paths are compiled into `chess-client.wasm`,
|
as `<origin>/chess`. Its API paths are compiled into `chess-client.wasm`,
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ go build ./cmd/chess-client-cli
|
|||||||
- `-finished-game-ttl`: How long terminal games stay in memory (default: `1h`; `0` disables eviction)
|
- `-finished-game-ttl`: How long terminal games stay in memory (default: `1h`; `0` disables eviction)
|
||||||
- `-anonymous-game-ttl`: How long games without a registered player survive after their last activity, in memory and in the database (default: `24h`; `0` keeps them)
|
- `-anonymous-game-ttl`: How long games without a registered player survive after their last activity, in memory and in the database (default: `24h`; `0` keeps them)
|
||||||
- `-max-users`: Accounts at which public registration closes (default: `100`; `0` = no limit; the CLI is not limited)
|
- `-max-users`: Accounts at which public registration closes (default: `100`; `0` = no limit; the CLI is not limited)
|
||||||
- `-web-api-url`: Browser-visible API origin for the embedded web client; useful when its public origin differs from the listen address
|
- `-web-api-url`: Browser-visible API origin for the embedded web client, set on its script element as `data-api`; useful when its public origin differs from the listen address
|
||||||
|
|
||||||
### Modes
|
### Modes
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+2
-1
@@ -120,7 +120,8 @@ Constraints from the host's security headers:
|
|||||||
classes instead of inline style strings.
|
classes instead of inline style strings.
|
||||||
- `img-src 'self' data:`: pieces remain Unicode glyphs or `data:` SVG.
|
- `img-src 'self' data:`: pieces remain Unicode glyphs or `data:` SVG.
|
||||||
- `connect-src 'self'`: all requests go to the same origin under `/chess/`
|
- `connect-src 'self'`: all requests go to the same origin under `/chess/`
|
||||||
(the existing `/config` fallback); no CDN or cross-origin API.
|
(the board's default API base, or the host's `data-api`); no CDN or
|
||||||
|
cross-origin API.
|
||||||
- `frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN`: the iframe host
|
- `frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN`: the iframe host
|
||||||
page must stay on the same origin. Downloads from `blob:` URLs and
|
page must stay on the same origin. Downloads from `blob:` URLs and
|
||||||
`navigator.clipboard` work in a same-origin frame.
|
`navigator.clipboard` work in a same-origin frame.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ let gameState = {
|
|||||||
isLocked: false,
|
isLocked: false,
|
||||||
polling: false,
|
polling: false,
|
||||||
pollController: null,
|
pollController: null,
|
||||||
apiUrl: '',
|
apiUrl: apiBase(document.currentScript),
|
||||||
selectedSquare: null,
|
selectedSquare: null,
|
||||||
healthCheckInterval: null,
|
healthCheckInterval: null,
|
||||||
networkError: false,
|
networkError: false,
|
||||||
@@ -49,10 +49,7 @@ function clearModalMessage(elementId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const config = await getConfig();
|
|
||||||
gameState.apiUrl = config.apiUrl;
|
|
||||||
|
|
||||||
// Check for existing session on load
|
// Check for existing session on load
|
||||||
restoreAuthSession();
|
restoreAuthSession();
|
||||||
|
|
||||||
@@ -471,21 +468,14 @@ function authFetch(url, options = {}) {
|
|||||||
return fetch(url, options);
|
return fetch(url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getConfig() {
|
// The API base, from the page that loads this script: its element's data-api,
|
||||||
try {
|
// set by the embedded server from -web-api-url and by a host that publishes
|
||||||
const response = await fetch('./config', { cache: 'no-store' });
|
// the files itself. Without it, /chess, where a static host proxies the API.
|
||||||
if (!response.ok) throw new Error(`config returned ${response.status}`);
|
// "/" is the origin's root. Read while the script runs: document.currentScript
|
||||||
const config = await response.json();
|
// is null in any callback.
|
||||||
if (typeof config.apiUrl !== 'string' || !config.apiUrl.trim()) {
|
function apiBase(script) {
|
||||||
throw new Error('config is missing apiUrl');
|
const api = script?.dataset.api?.trim();
|
||||||
}
|
return api ? api.replace(/\/+$/, '') : '/chess';
|
||||||
return { apiUrl: config.apiUrl.replace(/\/+$/, '') };
|
|
||||||
} catch (error) {
|
|
||||||
// Static production hosting currently reverse-proxies the API here.
|
|
||||||
// The embedded server supplies /config and does not use this fallback.
|
|
||||||
console.debug('Using default API route:', error.message);
|
|
||||||
return { apiUrl: '/chess' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startHealthCheck() {
|
function startHealthCheck() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package webserver
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -39,36 +40,30 @@ func Start(host string, port int, apiURL string, logRequests bool) error {
|
|||||||
return fmt.Errorf("failed to create web sub-filesystem: %w", err)
|
return fmt.Errorf("failed to create web sub-filesystem: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// API config endpoint, served before the static file handler
|
index, err := indexHTML(webContent, apiURL)
|
||||||
app.Get("/config", func(c *fiber.Ctx) error {
|
if err != nil {
|
||||||
return c.JSON(fiber.Map{
|
return err
|
||||||
"apiUrl": apiURL,
|
}
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Serve static files from the embedded client directory.
|
// Serve static files from the embedded client directory.
|
||||||
app.Get("*", func(c *fiber.Ctx) error {
|
app.Get("*", func(c *fiber.Ctx) error {
|
||||||
path := c.Path()
|
|
||||||
|
|
||||||
// Default to index.html for the root path
|
|
||||||
if path == "/" {
|
|
||||||
path = "/index.html"
|
|
||||||
}
|
|
||||||
|
|
||||||
// The path for the embedded filesystem must not have a leading slash
|
// The path for the embedded filesystem must not have a leading slash
|
||||||
fsPath := strings.TrimPrefix(path, "/")
|
fsPath := strings.TrimPrefix(c.Path(), "/")
|
||||||
|
|
||||||
|
sendIndex := func() error {
|
||||||
|
c.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
return c.Send(index)
|
||||||
|
}
|
||||||
|
if fsPath == "" || fsPath == "index.html" {
|
||||||
|
return sendIndex()
|
||||||
|
}
|
||||||
|
|
||||||
// Try to read the file
|
// Try to read the file
|
||||||
data, err := fs.ReadFile(webContent, fsPath)
|
data, err := fs.ReadFile(webContent, fsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If the file isn't found, serve index.html for SPA-style routing
|
// If the file isn't found, serve index.html for SPA-style routing
|
||||||
// This handles client-side routes that don't correspond to a file
|
// This handles client-side routes that don't correspond to a file
|
||||||
data, err = fs.ReadFile(webContent, "index.html")
|
return sendIndex()
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).SendString("index.html not found")
|
|
||||||
}
|
|
||||||
c.Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
return c.Send(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the correct Content-Type based on file extension
|
// Set the correct Content-Type based on file extension
|
||||||
@@ -89,3 +84,22 @@ func Start(host string, port int, apiURL string, logRequests bool) error {
|
|||||||
addr := fmt.Sprintf("%s:%d", host, port)
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
return app.Listen(addr)
|
return app.Listen(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// boardScript is how index.html loads the board; indexHTML adds the API base.
|
||||||
|
const boardScript = `<script src="app.js">`
|
||||||
|
|
||||||
|
// indexHTML returns the client's index.html with the browser-visible API base
|
||||||
|
// on the board's script element, data-api, which app.js reads as it runs: the
|
||||||
|
// same attribute a host that publishes the files itself sets.
|
||||||
|
func indexHTML(content fs.FS, apiURL string) ([]byte, error) {
|
||||||
|
data, err := fs.ReadFile(content, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("embedded index.html: %w", err)
|
||||||
|
}
|
||||||
|
page := string(data)
|
||||||
|
if n := strings.Count(page, boardScript); n != 1 {
|
||||||
|
return nil, fmt.Errorf("embedded index.html has %s %d times, want once", boardScript, n)
|
||||||
|
}
|
||||||
|
tag := fmt.Sprintf(`<script src="app.js" data-api="%s">`, html.EscapeString(apiURL))
|
||||||
|
return []byte(strings.Replace(page, boardScript, tag, 1)), nil
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package webserver
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEmbeddedWebClientRoot(t *testing.T) {
|
func TestEmbeddedWebClientRoot(t *testing.T) {
|
||||||
@@ -20,3 +22,37 @@ func TestEmbeddedWebClientRoot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIndexHTMLSetsAPIBase(t *testing.T) {
|
||||||
|
content, err := fs.Sub(webFS, "chess-client-web")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for apiURL, want := range map[string]string{
|
||||||
|
"http://localhost:8080": `<script src="app.js" data-api="http://localhost:8080">`,
|
||||||
|
`https://x.test/?a=1&b="2"`: `<script src="app.js" data-api="https://x.test/?a=1&b="2"">`,
|
||||||
|
} {
|
||||||
|
page, err := indexHTML(content, apiURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", apiURL, err)
|
||||||
|
}
|
||||||
|
if n := strings.Count(string(page), want); n != 1 {
|
||||||
|
t.Errorf("%s: index.html has %s %d times, want once", apiURL, want, n)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(page), boardScript) {
|
||||||
|
t.Errorf("%s: index.html still loads the board without data-api", apiURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexHTMLRefusesChangedScript(t *testing.T) {
|
||||||
|
for name, page := range map[string]string{
|
||||||
|
"absent": `<script src="./app.js"></script>`,
|
||||||
|
"twice": `<script src="app.js"></script><script src="app.js"></script>`,
|
||||||
|
} {
|
||||||
|
content := fstest.MapFS{"index.html": {Data: []byte(page)}}
|
||||||
|
if _, err := indexHTML(content, "http://localhost:8080"); err == nil {
|
||||||
|
t.Errorf("%s: no error", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user